trodo-python 2.4.1__tar.gz → 2.6.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.1 → trodo_python-2.6.0}/PKG-INFO +1 -1
  2. {trodo_python-2.4.1 → trodo_python-2.6.0}/pyproject.toml +1 -1
  3. trodo_python-2.6.0/tests/test_anon_distinct_id.py +100 -0
  4. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/processor.py +5 -5
  5. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/wrap_agent.py +81 -21
  6. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo_python.egg-info/PKG-INFO +1 -1
  7. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo_python.egg-info/SOURCES.txt +1 -0
  8. {trodo_python-2.4.1 → trodo_python-2.6.0}/README.md +0 -0
  9. {trodo_python-2.4.1 → trodo_python-2.6.0}/setup.cfg +0 -0
  10. {trodo_python-2.4.1 → trodo_python-2.6.0}/tests/test_auto_instrument_fixes.py +0 -0
  11. {trodo_python-2.4.1 → trodo_python-2.6.0}/tests/test_cross_process_session.py +0 -0
  12. {trodo_python-2.4.1 → trodo_python-2.6.0}/tests/test_end_run.py +0 -0
  13. {trodo_python-2.4.1 → trodo_python-2.6.0}/tests/test_processor_methods.py +0 -0
  14. {trodo_python-2.4.1 → trodo_python-2.6.0}/tests/test_register_otel.py +0 -0
  15. {trodo_python-2.4.1 → trodo_python-2.6.0}/tests/test_start_run.py +0 -0
  16. {trodo_python-2.4.1 → trodo_python-2.6.0}/tests/test_wrap_agent_unchanged.py +0 -0
  17. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/__init__.py +0 -0
  18. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/api/__init__.py +0 -0
  19. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/api/async_client.py +0 -0
  20. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/api/endpoints.py +0 -0
  21. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/api/http_client.py +0 -0
  22. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/auto/__init__.py +0 -0
  23. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/auto/auto_event_manager.py +0 -0
  24. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/client.py +0 -0
  25. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/managers/__init__.py +0 -0
  26. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/managers/group_manager.py +0 -0
  27. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/managers/people_manager.py +0 -0
  28. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/__init__.py +0 -0
  29. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/auto_instrument.py +0 -0
  30. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/context.py +0 -0
  31. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/helpers.py +0 -0
  32. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/register.py +0 -0
  33. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/otel/transport.py +0 -0
  34. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/queue/__init__.py +0 -0
  35. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/queue/batch_flusher.py +0 -0
  36. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/queue/event_queue.py +0 -0
  37. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/session/__init__.py +0 -0
  38. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/session/server_session.py +0 -0
  39. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/session/session_manager.py +0 -0
  40. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/types.py +0 -0
  41. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo/user_context.py +0 -0
  42. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  43. {trodo_python-2.4.1 → trodo_python-2.6.0}/trodo_python.egg-info/requires.txt +0 -0
  44. {trodo_python-2.4.1 → trodo_python-2.6.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.1
3
+ Version: 2.6.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.1"
7
+ version = "2.6.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"
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import threading
11
11
  from dataclasses import dataclass, asdict
12
- from typing import Any, Dict, List, Optional
12
+ from typing import Any, Dict, List, Optional, Union
13
13
 
14
14
 
15
15
  @dataclass
@@ -20,8 +20,8 @@ class TrodoRun:
20
20
  conversation_id: Optional[str] = None
21
21
  parent_run_id: Optional[str] = None
22
22
  status: str = "ok" # 'running' | 'ok' | 'error'
23
- input: Optional[str] = None
24
- output: Optional[str] = None
23
+ input: Optional[Union[str, Dict[str, Any]]] = None
24
+ output: Optional[Union[str, Dict[str, Any]]] = None
25
25
  started_at: Optional[str] = None
26
26
  ended_at: Optional[str] = None
27
27
  duration_ms: Optional[int] = None
@@ -50,8 +50,8 @@ class TrodoSpan:
50
50
  started_at: Optional[str] = None
51
51
  ended_at: Optional[str] = None
52
52
  duration_ms: Optional[int] = None
53
- input: Optional[str] = None
54
- output: Optional[str] = None
53
+ input: Optional[Union[str, Dict[str, Any]]] = None
54
+ output: Optional[Union[str, Dict[str, Any]]] = None
55
55
  error_type: Optional[str] = None
56
56
  error_message: Optional[str] = None
57
57
  model: Optional[str] = None
@@ -28,7 +28,7 @@ import json
28
28
  import time
29
29
  import uuid
30
30
  from datetime import datetime, timezone
31
- from typing import Any, Callable, Dict, Optional
31
+ from typing import Any, Callable, Dict, Optional, Union
32
32
 
33
33
  from .context import ActiveSpanContext, get_active_context, run_with_context
34
34
  from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
@@ -62,7 +62,11 @@ def _now_iso() -> str:
62
62
  return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
63
63
 
64
64
 
65
- def _truncate(value: Any, max_len: int = 64_000) -> Optional[str]:
65
+ _MAX_VALUE_LEN = 1_000_000 # 1 MB
66
+
67
+
68
+ def _truncate(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[str]:
69
+ """Coerce to string and truncate. Use for string-only contexts (OTel attrs, error summaries)."""
66
70
  if value is None:
67
71
  return None
68
72
  if isinstance(value, str):
@@ -75,6 +79,26 @@ def _truncate(value: Any, max_len: int = 64_000) -> Optional[str]:
75
79
  return s[:max_len] if len(s) > max_len else s
76
80
 
77
81
 
82
+ def _prepare_value(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[Union[str, Dict[str, Any]]]:
83
+ """Prepare a value for storage in the JSONB input/output column.
84
+
85
+ Dicts/lists pass through as-is (stored as JSONB objects/arrays).
86
+ Strings are truncated at max_len.
87
+ Everything else is JSON-serialised then truncated.
88
+ """
89
+ if value is None:
90
+ return None
91
+ if isinstance(value, dict):
92
+ return value
93
+ if isinstance(value, str):
94
+ return value[:max_len] if len(value) > max_len else value
95
+ try:
96
+ s = json.dumps(value, default=str)
97
+ except Exception:
98
+ s = str(value)
99
+ return s[:max_len] if len(s) > max_len else s
100
+
101
+
78
102
  def current_run_id() -> Optional[str]:
79
103
  """Return the run_id of the currently active agent run, if any."""
80
104
  ctx = get_active_context()
@@ -114,21 +138,46 @@ def _aggregate(spans: list[TrodoSpan]) -> Dict[str, Any]:
114
138
  }
115
139
 
116
140
 
141
+ def _mint_anon_distinct_id() -> str:
142
+ """Mint a server-side anonymous distinct_id for an agent run.
143
+
144
+ Mirrors UserIdentity.generateAnonymousDistinctId on the backend
145
+ (``anon_<ts>_<scope>_<uuid>_<rand>``) so server-SDK anon runs and
146
+ browser-SDK anon sessions share one prefix the dashboard already
147
+ filters on.
148
+
149
+ Minted client-side (not just server-side) so:
150
+ * the RunHandle / OTLP span attribute carries the id immediately and
151
+ downstream ``trodo.feedback(distinct_id=...)`` calls have something
152
+ to bind to,
153
+ * users running against an older backend still get a real attributable
154
+ distinct_id on every ``agent_runs`` row, instead of NULL.
155
+ """
156
+ import random
157
+ import string
158
+ ts = int(time.time() * 1000)
159
+ rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
160
+ return f"anon_{ts}_python_{uuid.uuid4()}_{rand}"
161
+
162
+
117
163
  class RunHandle:
118
164
  """Handle returned by wrap_agent for setting input/output and getting run_id."""
119
165
 
120
- def __init__(self, run_id: str, agent_name: str) -> None:
166
+ def __init__(self, run_id: str, agent_name: str, distinct_id: str) -> None:
121
167
  self.run_id = run_id
122
168
  self.agent_name = agent_name
123
- self.input: Optional[str] = None
124
- self.output: Optional[str] = None
169
+ # Always populated — wrap_agent mints anon if caller didn't pass one
170
+ # so downstream ``trodo.feedback(distinct_id=...)`` always has a target.
171
+ self.distinct_id = distinct_id
172
+ self.input: Optional[Union[str, Dict[str, Any]]] = None
173
+ self.output: Optional[Union[str, Dict[str, Any]]] = None
125
174
  self.metadata: Dict[str, Any] = {}
126
175
 
127
176
  def set_input(self, value: Any) -> None:
128
- self.input = _truncate(value)
177
+ self.input = _prepare_value(value)
129
178
 
130
179
  def set_output(self, value: Any) -> None:
131
- self.output = _truncate(value)
180
+ self.output = _prepare_value(value)
132
181
 
133
182
  def set_metadata(self, **kwargs: Any) -> None:
134
183
  self.metadata.update(kwargs)
@@ -140,8 +189,8 @@ class SpanHandle:
140
189
  def __init__(self, span_id: str, name: str) -> None:
141
190
  self.span_id = span_id
142
191
  self.name = name
143
- self.input: Optional[str] = None
144
- self.output: Optional[str] = None
192
+ self.input: Optional[Union[str, Dict[str, Any]]] = None
193
+ self.output: Optional[Union[str, Dict[str, Any]]] = None
145
194
  self.attributes: Dict[str, Any] = {}
146
195
  self.model: Optional[str] = None
147
196
  self.provider: Optional[str] = None
@@ -152,10 +201,10 @@ class SpanHandle:
152
201
  self.tool_name: Optional[str] = None
153
202
 
154
203
  def set_input(self, value: Any) -> None:
155
- self.input = _truncate(value)
204
+ self.input = _prepare_value(value)
156
205
 
157
206
  def set_output(self, value: Any) -> None:
158
- self.output = _truncate(value)
207
+ self.output = _prepare_value(value)
159
208
 
160
209
  def set_attribute(self, key: str, value: Any) -> None:
161
210
  self.attributes[key] = value
@@ -209,14 +258,20 @@ def start_run(
209
258
  to add spans — they flush incrementally via ``append_spans``.
210
259
  """
211
260
  rid = run_id or str(uuid.uuid4())
261
+ # Mint anon when missing so the run row is attributable. The minted id
262
+ # is stamped onto the TrodoRun payload but is not surfaced to the caller —
263
+ # start_run's signature returns just run_id for backward compat. Callers
264
+ # who need the distinct_id should pass their own (or use wrap_agent,
265
+ # whose RunHandle exposes it via handle.distinct_id).
266
+ effective_distinct_id = distinct_id or _mint_anon_distinct_id()
212
267
  run = TrodoRun(
213
268
  run_id=rid,
214
269
  agent_name=agent_name,
215
- distinct_id=distinct_id,
270
+ distinct_id=effective_distinct_id,
216
271
  conversation_id=conversation_id,
217
272
  parent_run_id=parent_run_id,
218
273
  status="running",
219
- input=_truncate(input),
274
+ input=_prepare_value(input),
220
275
  started_at=_now_iso(),
221
276
  metadata=metadata,
222
277
  )
@@ -248,7 +303,7 @@ def end_run(
248
303
  **agg,
249
304
  }
250
305
  if output is not None:
251
- payload["output"] = _truncate(output)
306
+ payload["output"] = _prepare_value(output)
252
307
  if error_summary is not None:
253
308
  payload["error_summary"] = _truncate(error_summary, 4_000)
254
309
  if metadata is not None:
@@ -278,7 +333,11 @@ class wrap_agent:
278
333
  self._processor = processor
279
334
  self._team_site_id = team_site_id
280
335
  self._agent_name = agent_name
281
- self._distinct_id = distinct_id
336
+ # Mint anon when caller didn't pass one. From this point on every
337
+ # internal path uses self._distinct_id, never the raw constructor
338
+ # argument, so the run row, OTLP span attribute, and RunHandle all
339
+ # agree on a single non-null value.
340
+ self._distinct_id = distinct_id or _mint_anon_distinct_id()
282
341
  self._conversation_id = conversation_id
283
342
  self._parent_run_id = parent_run_id
284
343
  self._metadata = metadata
@@ -312,7 +371,7 @@ class wrap_agent:
312
371
  self._started_iso = _now_iso()
313
372
  self._started_ms = time.time() * 1000.0
314
373
 
315
- self.handle = RunHandle(run_id, self._agent_name)
374
+ self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
316
375
  ctx = ActiveSpanContext(
317
376
  run_id=run_id,
318
377
  span_id=root_span_id,
@@ -387,8 +446,9 @@ class wrap_agent:
387
446
  run_id = _hex_to_uuid(trace_id_hex)
388
447
 
389
448
  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))
449
+ # self._distinct_id is always set (anon-minted in __init__ when caller
450
+ # didn't pass one), so the attribute always lands on the OTLP span.
451
+ otel_span.set_attribute("trodo.distinct_id", str(self._distinct_id))
392
452
  if self._conversation_id:
393
453
  otel_span.set_attribute("trodo.conversation_id", str(self._conversation_id))
394
454
  if self._parent_run_id:
@@ -398,7 +458,7 @@ class wrap_agent:
398
458
  otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
399
459
 
400
460
  self._otel_span = otel_span
401
- self.handle = RunHandle(run_id, self._agent_name)
461
+ self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
402
462
  return self.handle
403
463
 
404
464
  def _exit_otel(self, exc_type, exc, tb) -> None:
@@ -457,7 +517,7 @@ class join_run:
457
517
  self._parent_span_id = parent_span_id
458
518
  self._name = name
459
519
  self._kind = kind
460
- self._input = _truncate(input) if input is not None else None
520
+ self._input = _prepare_value(input) if input is not None else None
461
521
  self._attributes = attributes
462
522
  self._ctx_mgr: Optional[run_with_context] = None
463
523
  self._started_ms: float = 0.0
@@ -544,7 +604,7 @@ class span:
544
604
  ) -> None:
545
605
  self._name = name
546
606
  self._kind = kind
547
- self._input = _truncate(input) if input is not None else None
607
+ self._input = _prepare_value(input) if input is not None else None
548
608
  self._attributes = attributes
549
609
  self._ctx_mgr: Optional[run_with_context] = None
550
610
  self._started_ms: float = 0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.4.1
3
+ Version: 2.6.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,6 @@
1
1
  README.md
2
2
  pyproject.toml
3
+ tests/test_anon_distinct_id.py
3
4
  tests/test_auto_instrument_fixes.py
4
5
  tests/test_cross_process_session.py
5
6
  tests/test_end_run.py
File without changes
File without changes