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.
- {trodo_python-2.4.0 → trodo_python-2.5.0}/PKG-INFO +1 -1
- {trodo_python-2.4.0 → trodo_python-2.5.0}/pyproject.toml +1 -1
- trodo_python-2.5.0/tests/test_anon_distinct_id.py +100 -0
- trodo_python-2.5.0/tests/test_auto_instrument_fixes.py +111 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/auto_instrument.py +54 -12
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/helpers.py +9 -4
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/wrap_agent.py +43 -7
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/PKG-INFO +1 -1
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/SOURCES.txt +2 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/README.md +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/setup.cfg +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/__init__.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/api/http_client.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/client.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/processor.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/types.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo/user_context.py +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.4.0 → trodo_python-2.5.0}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
75
|
+
otel_parent_span_id: Optional[str] = None
|
|
58
76
|
if parent is not None:
|
|
59
77
|
pid = getattr(parent, "span_id", None)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
otel_parent_span_id = (
|
|
79
|
+
f"{pid:016x}" if isinstance(pid, int) else str(pid) if pid else None
|
|
80
|
+
)
|
|
63
81
|
|
|
64
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|