trodo-python 2.4.0__tar.gz → 2.5.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 (44) hide show
  1. {trodo_python-2.4.0 → trodo_python-2.5.0}/PKG-INFO +1 -1
  2. {trodo_python-2.4.0 → trodo_python-2.5.0}/pyproject.toml +1 -1
  3. trodo_python-2.5.0/tests/test_anon_distinct_id.py +100 -0
  4. trodo_python-2.5.0/tests/test_auto_instrument_fixes.py +111 -0
  5. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/auto_instrument.py +54 -12
  6. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/helpers.py +9 -4
  7. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/wrap_agent.py +43 -7
  8. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/PKG-INFO +1 -1
  9. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/SOURCES.txt +2 -0
  10. {trodo_python-2.4.0 → trodo_python-2.5.0}/README.md +0 -0
  11. {trodo_python-2.4.0 → trodo_python-2.5.0}/setup.cfg +0 -0
  12. {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_cross_process_session.py +0 -0
  13. {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_end_run.py +0 -0
  14. {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_processor_methods.py +0 -0
  15. {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_register_otel.py +0 -0
  16. {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_start_run.py +0 -0
  17. {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_wrap_agent_unchanged.py +0 -0
  18. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/__init__.py +0 -0
  19. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/__init__.py +0 -0
  20. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/async_client.py +0 -0
  21. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/endpoints.py +0 -0
  22. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/http_client.py +0 -0
  23. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/auto/__init__.py +0 -0
  24. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/auto/auto_event_manager.py +0 -0
  25. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/client.py +0 -0
  26. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/managers/__init__.py +0 -0
  27. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/managers/group_manager.py +0 -0
  28. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/managers/people_manager.py +0 -0
  29. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/__init__.py +0 -0
  30. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/context.py +0 -0
  31. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/processor.py +0 -0
  32. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/register.py +0 -0
  33. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/transport.py +0 -0
  34. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/queue/__init__.py +0 -0
  35. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/queue/batch_flusher.py +0 -0
  36. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/queue/event_queue.py +0 -0
  37. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/session/__init__.py +0 -0
  38. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/session/server_session.py +0 -0
  39. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/session/session_manager.py +0 -0
  40. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/types.py +0 -0
  41. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/user_context.py +0 -0
  42. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  43. {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/requires.txt +0 -0
  44. {trodo_python-2.4.0 → trodo_python-2.5.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.4.0
3
+ Version: 2.5.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.4.0"
7
+ version = "2.5.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,100 @@
1
+ """Anonymous distinct_id minting on agent surfaces.
2
+
3
+ When the caller doesn't pass ``distinct_id``, the SDK mints an
4
+ ``anon_<ts>_python_<uuid>_<rand>`` id so:
5
+
6
+ * the agent_runs row always lands with a non-null distinct_id,
7
+ * the RunHandle exposes the same id so callers can bind
8
+ ``trodo.feedback(distinct_id=...)`` later,
9
+ * older backends without identity-resolution still attribute the row.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from trodo.otel.wrap_agent import _mint_anon_distinct_id, start_run, wrap_agent
16
+
17
+
18
+ ANON_RE = re.compile(r"^anon_\d+_python_")
19
+
20
+
21
+ def test_mint_anon_distinct_id_shape():
22
+ a = _mint_anon_distinct_id()
23
+ assert ANON_RE.match(a)
24
+
25
+
26
+ def test_mint_anon_distinct_id_unique_under_load():
27
+ ids = {_mint_anon_distinct_id() for _ in range(1000)}
28
+ assert len(ids) == 1000
29
+
30
+
31
+ def test_wrap_agent_mints_anon_when_distinct_id_omitted(processor, http):
32
+ observed = {}
33
+ with wrap_agent(
34
+ processor=processor,
35
+ team_site_id="site-x",
36
+ agent_name="chat",
37
+ ) as run:
38
+ observed["distinct_id"] = run.distinct_id
39
+ run.set_output("done")
40
+
41
+ assert ANON_RE.match(observed["distinct_id"])
42
+ assert len(http.run_ingest) == 1
43
+ assert http.run_ingest[0]["run"]["distinct_id"] == observed["distinct_id"]
44
+
45
+
46
+ def test_wrap_agent_respects_explicit_distinct_id(processor, http):
47
+ with wrap_agent(
48
+ processor=processor,
49
+ team_site_id="site-x",
50
+ agent_name="chat",
51
+ distinct_id="user-42",
52
+ ) as run:
53
+ assert run.distinct_id == "user-42"
54
+
55
+ assert http.run_ingest[0]["run"]["distinct_id"] == "user-42"
56
+
57
+
58
+ def test_wrap_agent_mints_different_anon_ids_across_calls(processor, http):
59
+ seen = []
60
+ for _ in range(3):
61
+ with wrap_agent(
62
+ processor=processor,
63
+ team_site_id="site-x",
64
+ agent_name="chat",
65
+ ) as run:
66
+ seen.append(run.distinct_id)
67
+ assert len(set(seen)) == 3
68
+
69
+
70
+ def test_wrap_agent_still_mints_anon_on_error(processor, http):
71
+ import pytest
72
+ with pytest.raises(ValueError):
73
+ with wrap_agent(
74
+ processor=processor,
75
+ team_site_id="site-x",
76
+ agent_name="chat",
77
+ ) as _run:
78
+ raise ValueError("boom")
79
+
80
+ assert len(http.run_ingest) == 1
81
+ payload = http.run_ingest[0]["run"]
82
+ assert payload["status"] == "error"
83
+ assert ANON_RE.match(payload["distinct_id"])
84
+
85
+
86
+ def test_start_run_mints_anon_when_distinct_id_omitted(processor, http):
87
+ start_run(processor=processor, agent_name="external_session")
88
+ assert len(http.run_start) == 1
89
+ run = http.run_start[0]["run"]
90
+ assert ANON_RE.match(run["distinct_id"])
91
+
92
+
93
+ def test_start_run_respects_explicit_distinct_id(processor, http):
94
+ start_run(
95
+ processor=processor,
96
+ agent_name="external_session",
97
+ distinct_id="user-7",
98
+ )
99
+ run = http.run_start[0]["run"]
100
+ assert run["distinct_id"] == "user-7"
@@ -0,0 +1,111 @@
1
+ """Mirror of trodo-node 2.4.3 bridge fixes for the Python SDK.
2
+
3
+ Covers:
4
+ 1. duration_ms is rounded to an integer.
5
+ 2. Active run is recovered from on_start-stamped attributes when
6
+ contextvars are empty at on_end (httpx async-context loss).
7
+ 3. Internal trodo.* attributes are stripped from the persisted span.
8
+ """
9
+
10
+ from types import SimpleNamespace
11
+ from unittest.mock import MagicMock
12
+
13
+ import pytest
14
+
15
+ from trodo.otel.auto_instrument import _OtelAdapter, otel_span_to_trodo_span
16
+ from trodo.otel.context import (
17
+ ActiveSpanContext,
18
+ get_active_context,
19
+ run_with_context,
20
+ )
21
+
22
+
23
+ RUN_ID = "11111111-1111-1111-1111-111111111111"
24
+ ROOT_SPAN_ID = "22222222-2222-2222-2222-222222222222"
25
+
26
+
27
+ def _fake_otel_span(start_ns=None, end_ns=None, attrs=None, span_id_int=0xABCD1234ABCD1234, parent_span_id_int=None):
28
+ span_context = SimpleNamespace(span_id=span_id_int)
29
+ parent = SimpleNamespace(span_id=parent_span_id_int) if parent_span_id_int else None
30
+ return SimpleNamespace(
31
+ name="anthropic.messages.create",
32
+ get_span_context=lambda: span_context,
33
+ attributes=attrs or {},
34
+ start_time=start_ns,
35
+ end_time=end_ns,
36
+ status=None,
37
+ parent=parent,
38
+ )
39
+
40
+
41
+ def _ctx():
42
+ return ActiveSpanContext(
43
+ run_id=RUN_ID,
44
+ span_id=ROOT_SPAN_ID,
45
+ parent_span_id=None,
46
+ team_site_id="site-1",
47
+ processor=None, # type: ignore[arg-type]
48
+ )
49
+
50
+
51
+ class TestDurationRounding:
52
+ def test_fractional_ns_delta_rounds_to_integer(self):
53
+ # 1000ms + 0.999888 fractional ms via nanos delta
54
+ otel = _fake_otel_span(start_ns=1_000_000_000, end_ns=2_000_999_888)
55
+ with run_with_context(_ctx()):
56
+ span = otel_span_to_trodo_span(otel)
57
+ assert span is not None
58
+ assert isinstance(span.duration_ms, int)
59
+ assert span.duration_ms == 1001
60
+
61
+ def test_missing_times_leaves_duration_none(self):
62
+ otel = _fake_otel_span(start_ns=None, end_ns=None)
63
+ with run_with_context(_ctx()):
64
+ span = otel_span_to_trodo_span(otel)
65
+ assert span.duration_ms is None
66
+
67
+
68
+ class TestOnStartStamping:
69
+ def test_on_start_stamps_run_id_when_context_is_alive(self):
70
+ adapter = _OtelAdapter(processor=MagicMock())
71
+ live_span = MagicMock()
72
+ with run_with_context(_ctx()):
73
+ adapter.on_start(live_span, None)
74
+ live_span.set_attribute.assert_any_call("trodo.run_id", RUN_ID)
75
+ live_span.set_attribute.assert_any_call("trodo.parent_span_id", ROOT_SPAN_ID)
76
+
77
+ def test_recovers_run_from_stamped_attrs_when_contextvars_empty(self):
78
+ # Simulate the failure mode: span fires on_end outside any wrap_agent.
79
+ assert get_active_context() is None
80
+ otel = _fake_otel_span(
81
+ start_ns=1_000_000_000,
82
+ end_ns=2_000_000_000,
83
+ attrs={
84
+ "trodo.run_id": RUN_ID,
85
+ "trodo.parent_span_id": ROOT_SPAN_ID,
86
+ "gen_ai.request.model": "claude-3-7",
87
+ },
88
+ )
89
+ span = otel_span_to_trodo_span(otel)
90
+ assert span is not None
91
+ assert span.run_id == RUN_ID
92
+ assert span.parent_span_id == ROOT_SPAN_ID
93
+
94
+ def test_strips_trodo_attrs_from_persisted_span(self):
95
+ otel = _fake_otel_span(
96
+ start_ns=1_000_000_000,
97
+ end_ns=2_000_000_000,
98
+ attrs={
99
+ "trodo.run_id": RUN_ID,
100
+ "trodo.parent_span_id": ROOT_SPAN_ID,
101
+ "gen_ai.request.model": "gpt-4",
102
+ },
103
+ )
104
+ span = otel_span_to_trodo_span(otel)
105
+ assert "trodo.run_id" not in (span.attributes or {})
106
+ assert "trodo.parent_span_id" not in (span.attributes or {})
107
+ assert (span.attributes or {}).get("gen_ai.request.model") == "gpt-4"
108
+
109
+ def test_drops_span_when_no_run_id_anywhere(self):
110
+ otel = _fake_otel_span(start_ns=1, end_ns=2, attrs={})
111
+ assert otel_span_to_trodo_span(otel) is None
@@ -34,11 +34,17 @@ def _infer_kind(attrs: Dict[str, Any]) -> str:
34
34
  return "generic"
35
35
 
36
36
 
37
+ _ATTR_TRODO_RUN_ID = "trodo.run_id"
38
+ _ATTR_TRODO_PARENT_SPAN_ID = "trodo.parent_span_id"
39
+
40
+
37
41
  def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
38
- """Translate an OTel ReadableSpan to our TrodoSpan using GenAI semconv."""
39
- ctx = get_active_context()
40
- if ctx is None:
41
- return None # span emitted outside of wrap_agent drop
42
+ """Translate an OTel ReadableSpan to our TrodoSpan using GenAI semconv.
43
+
44
+ Recovers the active run from on_start-stamped attributes when contextvars
45
+ have been clobbered by httpx/asyncio context loss at span end. Mirrors
46
+ the trodo-node 2.4.3 bridge behaviour.
47
+ """
42
48
  try:
43
49
  span_ctx = (
44
50
  otel_span.get_span_context()
@@ -53,15 +59,36 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
53
59
  return None
54
60
  span_id = f"{span_id_int:016x}" if isinstance(span_id_int, int) else str(span_id_int)
55
61
 
62
+ attrs = dict(getattr(otel_span, "attributes", {}) or {})
63
+
64
+ # Prefer on_start-stamped run/parent so async-context loss can't drop
65
+ # auto-instrumented LLM spans.
66
+ stamped_run_id = attrs.get(_ATTR_TRODO_RUN_ID)
67
+ stamped_parent_span_id = attrs.get(_ATTR_TRODO_PARENT_SPAN_ID)
68
+
69
+ ctx = get_active_context()
70
+ run_id = stamped_run_id if isinstance(stamped_run_id, str) else (ctx.run_id if ctx else None)
71
+ if not run_id:
72
+ return None # emitted outside any wrap_agent — drop
73
+
56
74
  parent = getattr(otel_span, "parent", None)
57
- parent_span_id: Optional[str] = None
75
+ otel_parent_span_id: Optional[str] = None
58
76
  if parent is not None:
59
77
  pid = getattr(parent, "span_id", None)
60
- parent_span_id = f"{pid:016x}" if isinstance(pid, int) else str(pid) if pid else None
61
- if parent_span_id is None:
62
- parent_span_id = ctx.span_id
78
+ otel_parent_span_id = (
79
+ f"{pid:016x}" if isinstance(pid, int) else str(pid) if pid else None
80
+ )
63
81
 
64
- attrs = dict(getattr(otel_span, "attributes", {}) or {})
82
+ if isinstance(stamped_parent_span_id, str):
83
+ parent_span_id: Optional[str] = stamped_parent_span_id
84
+ elif otel_parent_span_id:
85
+ parent_span_id = otel_parent_span_id
86
+ else:
87
+ parent_span_id = ctx.span_id if ctx else None
88
+
89
+ # Strip the internal attrs so they don't leak into the persisted span.
90
+ attrs.pop(_ATTR_TRODO_RUN_ID, None)
91
+ attrs.pop(_ATTR_TRODO_PARENT_SPAN_ID, None)
65
92
  kind = _infer_kind(attrs)
66
93
 
67
94
  start_time = getattr(otel_span, "start_time", None)
@@ -70,7 +97,9 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
70
97
  ended_at = _hr_to_iso(end_time)
71
98
  duration_ms = None
72
99
  if start_time and end_time:
73
- duration_ms = max(0, int((end_time - start_time) / 1e6))
100
+ # round-half-to-even semantics are fine here; the constraint is
101
+ # "integer ms" matching the agent_spans.duration_ms column.
102
+ duration_ms = max(0, round((end_time - start_time) / 1e6))
74
103
 
75
104
  status = getattr(otel_span, "status", None)
76
105
  status_code = getattr(status, "status_code", None)
@@ -104,7 +133,7 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
104
133
 
105
134
  return TrodoSpan(
106
135
  span_id=span_id,
107
- run_id=ctx.run_id,
136
+ run_id=run_id,
108
137
  parent_span_id=parent_span_id,
109
138
  kind=kind,
110
139
  name=getattr(otel_span, "name", kind),
@@ -131,7 +160,20 @@ class _OtelAdapter:
131
160
  self._processor = processor
132
161
 
133
162
  def on_start(self, span: Any, parent_context: Any = None) -> None:
134
- pass
163
+ # Stamp active run/parent ids while contextvars are still alive.
164
+ # See otel_span_to_trodo_span() docstring for the failure mode this
165
+ # guards against (async-context loss across httpx await boundaries).
166
+ ctx = get_active_context()
167
+ if ctx is None:
168
+ return
169
+ try:
170
+ set_attr = getattr(span, "set_attribute", None)
171
+ if callable(set_attr):
172
+ set_attr(_ATTR_TRODO_RUN_ID, ctx.run_id)
173
+ if ctx.span_id:
174
+ set_attr(_ATTR_TRODO_PARENT_SPAN_ID, ctx.span_id)
175
+ except Exception:
176
+ pass # never break user code
135
177
 
136
178
  def on_end(self, span: Any) -> None:
137
179
  trodo_span = otel_span_to_trodo_span(span)
@@ -320,14 +320,19 @@ def track_mcp(
320
320
  span_id = str(_uuid.uuid4())
321
321
  conv_id = session_id or str(_uuid.uuid4())
322
322
 
323
+ # Coerce duration to a non-negative integer. agent_spans.duration_ms is an
324
+ # integer column and callers occasionally pass floats from time.perf_counter()
325
+ # deltas — without this, fractional values 500 the ingest.
326
+ if isinstance(duration_ms, (int, float)) and duration_ms > 0:
327
+ duration_ms = int(round(duration_ms))
328
+ else:
329
+ duration_ms = 0
330
+
323
331
  now = datetime.now(timezone.utc)
324
332
  if ended_at is None:
325
333
  ended_at = now.isoformat()
326
334
  if started_at is None:
327
- offset_ms = duration_ms if isinstance(duration_ms, int) and duration_ms > 0 else 0
328
- started_at = (now - timedelta(milliseconds=offset_ms)).isoformat()
329
- if duration_ms is None:
330
- duration_ms = 0
335
+ started_at = (now - timedelta(milliseconds=duration_ms)).isoformat()
331
336
 
332
337
  status = "error" if error else "ok"
333
338
  if error:
@@ -114,12 +114,37 @@ def _aggregate(spans: list[TrodoSpan]) -> Dict[str, Any]:
114
114
  }
115
115
 
116
116
 
117
+ def _mint_anon_distinct_id() -> str:
118
+ """Mint a server-side anonymous distinct_id for an agent run.
119
+
120
+ Mirrors UserIdentity.generateAnonymousDistinctId on the backend
121
+ (``anon_<ts>_<scope>_<uuid>_<rand>``) so server-SDK anon runs and
122
+ browser-SDK anon sessions share one prefix the dashboard already
123
+ filters on.
124
+
125
+ Minted client-side (not just server-side) so:
126
+ * the RunHandle / OTLP span attribute carries the id immediately and
127
+ downstream ``trodo.feedback(distinct_id=...)`` calls have something
128
+ to bind to,
129
+ * users running against an older backend still get a real attributable
130
+ distinct_id on every ``agent_runs`` row, instead of NULL.
131
+ """
132
+ import random
133
+ import string
134
+ ts = int(time.time() * 1000)
135
+ rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
136
+ return f"anon_{ts}_python_{uuid.uuid4()}_{rand}"
137
+
138
+
117
139
  class RunHandle:
118
140
  """Handle returned by wrap_agent for setting input/output and getting run_id."""
119
141
 
120
- def __init__(self, run_id: str, agent_name: str) -> None:
142
+ def __init__(self, run_id: str, agent_name: str, distinct_id: str) -> None:
121
143
  self.run_id = run_id
122
144
  self.agent_name = agent_name
145
+ # Always populated — wrap_agent mints anon if caller didn't pass one
146
+ # so downstream ``trodo.feedback(distinct_id=...)`` always has a target.
147
+ self.distinct_id = distinct_id
123
148
  self.input: Optional[str] = None
124
149
  self.output: Optional[str] = None
125
150
  self.metadata: Dict[str, Any] = {}
@@ -209,10 +234,16 @@ def start_run(
209
234
  to add spans — they flush incrementally via ``append_spans``.
210
235
  """
211
236
  rid = run_id or str(uuid.uuid4())
237
+ # Mint anon when missing so the run row is attributable. The minted id
238
+ # is stamped onto the TrodoRun payload but is not surfaced to the caller —
239
+ # start_run's signature returns just run_id for backward compat. Callers
240
+ # who need the distinct_id should pass their own (or use wrap_agent,
241
+ # whose RunHandle exposes it via handle.distinct_id).
242
+ effective_distinct_id = distinct_id or _mint_anon_distinct_id()
212
243
  run = TrodoRun(
213
244
  run_id=rid,
214
245
  agent_name=agent_name,
215
- distinct_id=distinct_id,
246
+ distinct_id=effective_distinct_id,
216
247
  conversation_id=conversation_id,
217
248
  parent_run_id=parent_run_id,
218
249
  status="running",
@@ -278,7 +309,11 @@ class wrap_agent:
278
309
  self._processor = processor
279
310
  self._team_site_id = team_site_id
280
311
  self._agent_name = agent_name
281
- self._distinct_id = distinct_id
312
+ # Mint anon when caller didn't pass one. From this point on every
313
+ # internal path uses self._distinct_id, never the raw constructor
314
+ # argument, so the run row, OTLP span attribute, and RunHandle all
315
+ # agree on a single non-null value.
316
+ self._distinct_id = distinct_id or _mint_anon_distinct_id()
282
317
  self._conversation_id = conversation_id
283
318
  self._parent_run_id = parent_run_id
284
319
  self._metadata = metadata
@@ -312,7 +347,7 @@ class wrap_agent:
312
347
  self._started_iso = _now_iso()
313
348
  self._started_ms = time.time() * 1000.0
314
349
 
315
- self.handle = RunHandle(run_id, self._agent_name)
350
+ self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
316
351
  ctx = ActiveSpanContext(
317
352
  run_id=run_id,
318
353
  span_id=root_span_id,
@@ -387,8 +422,9 @@ class wrap_agent:
387
422
  run_id = _hex_to_uuid(trace_id_hex)
388
423
 
389
424
  otel_span.set_attribute("trodo.agent_name", self._agent_name)
390
- if self._distinct_id:
391
- otel_span.set_attribute("trodo.distinct_id", str(self._distinct_id))
425
+ # self._distinct_id is always set (anon-minted in __init__ when caller
426
+ # didn't pass one), so the attribute always lands on the OTLP span.
427
+ otel_span.set_attribute("trodo.distinct_id", str(self._distinct_id))
392
428
  if self._conversation_id:
393
429
  otel_span.set_attribute("trodo.conversation_id", str(self._conversation_id))
394
430
  if self._parent_run_id:
@@ -398,7 +434,7 @@ class wrap_agent:
398
434
  otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
399
435
 
400
436
  self._otel_span = otel_span
401
- self.handle = RunHandle(run_id, self._agent_name)
437
+ self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
402
438
  return self.handle
403
439
 
404
440
  def _exit_otel(self, exc_type, exc, tb) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.4.0
3
+ Version: 2.5.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,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
+ tests/test_anon_distinct_id.py
4
+ tests/test_auto_instrument_fixes.py
3
5
  tests/test_cross_process_session.py
4
6
  tests/test_end_run.py
5
7
  tests/test_processor_methods.py
File without changes
File without changes