trodo-python 2.1.0__tar.gz → 2.2.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.1.0 → trodo_python-2.2.0}/PKG-INFO +29 -1
- {trodo_python-2.1.0 → trodo_python-2.2.0}/README.md +28 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/pyproject.toml +1 -1
- trodo_python-2.2.0/tests/test_cross_process_session.py +86 -0
- trodo_python-2.2.0/tests/test_end_run.py +90 -0
- trodo_python-2.2.0/tests/test_processor_methods.py +34 -0
- trodo_python-2.2.0/tests/test_start_run.py +65 -0
- trodo_python-2.2.0/tests/test_wrap_agent_unchanged.py +49 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/__init__.py +49 -1
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/client.py +47 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/otel/processor.py +19 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/otel/wrap_agent.py +70 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo_python.egg-info/PKG-INFO +29 -1
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo_python.egg-info/SOURCES.txt +5 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/setup.cfg +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/api/http_client.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/types.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo/user_context.py +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.1.0 → trodo_python-2.2.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.
|
|
3
|
+
Version: 2.2.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
|
|
@@ -263,6 +263,34 @@ with trodo.join_run(
|
|
|
263
263
|
...
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
+
### Long-lived sessions across processes — `start_run` / `end_run`
|
|
267
|
+
|
|
268
|
+
`wrap_agent` is a context manager — it opens *and* closes the run in one
|
|
269
|
+
call stack. For sessions that live across many HTTP requests (an MCP
|
|
270
|
+
server, a websocket-pinned chat, scheduled jobs that resume on different
|
|
271
|
+
workers), use `start_run` to open the run from one process and `end_run`
|
|
272
|
+
to finalise it later. Between the two, any process can use `join_run` to
|
|
273
|
+
add child spans. Same `run_id` threads through everything.
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
# Process A — open the run for an MCP session.
|
|
277
|
+
run_id = trodo.start_run(
|
|
278
|
+
'external_mcp_session',
|
|
279
|
+
distinct_id=str(user_id),
|
|
280
|
+
conversation_id=mcp_session_id,
|
|
281
|
+
)
|
|
282
|
+
redis.set(f"mcp:run:{mcp_session_id}", run_id, ex=3600)
|
|
283
|
+
|
|
284
|
+
# Process B (later, possibly a different worker) — append a tool span.
|
|
285
|
+
run_id = redis.get(f"mcp:run:{mcp_session_id}").decode()
|
|
286
|
+
with trodo.join_run(run_id, name='tool.run_funnel_query', kind='tool') as span:
|
|
287
|
+
span.set_input(args)
|
|
288
|
+
span.set_output(result)
|
|
289
|
+
|
|
290
|
+
# When the session ends (timeout sweeper, explicit close):
|
|
291
|
+
trodo.end_run(run_id, status='ok')
|
|
292
|
+
```
|
|
293
|
+
|
|
266
294
|
### Conversation binding & feedback
|
|
267
295
|
|
|
268
296
|
```python
|
|
@@ -236,6 +236,34 @@ with trodo.join_run(
|
|
|
236
236
|
...
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
+
### Long-lived sessions across processes — `start_run` / `end_run`
|
|
240
|
+
|
|
241
|
+
`wrap_agent` is a context manager — it opens *and* closes the run in one
|
|
242
|
+
call stack. For sessions that live across many HTTP requests (an MCP
|
|
243
|
+
server, a websocket-pinned chat, scheduled jobs that resume on different
|
|
244
|
+
workers), use `start_run` to open the run from one process and `end_run`
|
|
245
|
+
to finalise it later. Between the two, any process can use `join_run` to
|
|
246
|
+
add child spans. Same `run_id` threads through everything.
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# Process A — open the run for an MCP session.
|
|
250
|
+
run_id = trodo.start_run(
|
|
251
|
+
'external_mcp_session',
|
|
252
|
+
distinct_id=str(user_id),
|
|
253
|
+
conversation_id=mcp_session_id,
|
|
254
|
+
)
|
|
255
|
+
redis.set(f"mcp:run:{mcp_session_id}", run_id, ex=3600)
|
|
256
|
+
|
|
257
|
+
# Process B (later, possibly a different worker) — append a tool span.
|
|
258
|
+
run_id = redis.get(f"mcp:run:{mcp_session_id}").decode()
|
|
259
|
+
with trodo.join_run(run_id, name='tool.run_funnel_query', kind='tool') as span:
|
|
260
|
+
span.set_input(args)
|
|
261
|
+
span.set_output(result)
|
|
262
|
+
|
|
263
|
+
# When the session ends (timeout sweeper, explicit close):
|
|
264
|
+
trodo.end_run(run_id, status='ok')
|
|
265
|
+
```
|
|
266
|
+
|
|
239
267
|
### Conversation binding & feedback
|
|
240
268
|
|
|
241
269
|
```python
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""End-to-end test of the new MCP-style flow:
|
|
2
|
+
|
|
3
|
+
1. process A calls start_run, gets back run_id, persists it (Redis in prod)
|
|
4
|
+
2. process B receives a request, looks up run_id, uses join_run to add a span
|
|
5
|
+
3. eventually process C (or a sweeper) calls end_run
|
|
6
|
+
|
|
7
|
+
This is the use case `start_run`/`end_run` were added to support, and is
|
|
8
|
+
*not* expressible with `wrap_agent` alone (it's a single-context-manager block).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from trodo.otel.processor import TrodoSpanProcessor
|
|
13
|
+
from trodo.otel.wrap_agent import start_run, end_run, join_run
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_caller_supplied_run_id_threads_through(http):
|
|
17
|
+
proc_a = TrodoSpanProcessor(http_client=http)
|
|
18
|
+
proc_b = TrodoSpanProcessor(http_client=http) # different process
|
|
19
|
+
|
|
20
|
+
# Process A: open the session run.
|
|
21
|
+
rid = start_run(
|
|
22
|
+
processor=proc_a,
|
|
23
|
+
agent_name="external_mcp_session",
|
|
24
|
+
run_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
|
25
|
+
conversation_id="mcp-sess-42",
|
|
26
|
+
)
|
|
27
|
+
assert rid == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
28
|
+
|
|
29
|
+
# Process B: receive a tools/call request, look up rid from Redis (here
|
|
30
|
+
# we just have it), append a tool span via join_run.
|
|
31
|
+
with join_run(
|
|
32
|
+
processor=proc_b,
|
|
33
|
+
team_site_id="site-x",
|
|
34
|
+
run_id=rid,
|
|
35
|
+
name="tool.run_funnel_query",
|
|
36
|
+
kind="tool",
|
|
37
|
+
) as span:
|
|
38
|
+
span.set_input({"team_id": "team-1"})
|
|
39
|
+
span.set_output({"status": "ok"})
|
|
40
|
+
|
|
41
|
+
# Process C (sweeper): finalise.
|
|
42
|
+
end_run(rid, processor=proc_a, output={"calls": 1}, status="ok")
|
|
43
|
+
|
|
44
|
+
# Verify wire shape:
|
|
45
|
+
assert len(http.run_start) == 1, "exactly one /runs/start"
|
|
46
|
+
assert http.run_start[0]["run"]["run_id"] == rid
|
|
47
|
+
assert http.run_start[0]["run"]["conversation_id"] == "mcp-sess-42"
|
|
48
|
+
|
|
49
|
+
assert len(http.spans_append) == 1, "join_run flushes span via /runs/{id}/spans"
|
|
50
|
+
sent_rid, spans = http.spans_append[0]
|
|
51
|
+
assert sent_rid == rid
|
|
52
|
+
assert spans[0]["name"] == "tool.run_funnel_query"
|
|
53
|
+
assert spans[0]["kind"] == "tool"
|
|
54
|
+
assert spans[0]["run_id"] == rid
|
|
55
|
+
|
|
56
|
+
assert len(http.run_end) == 1, "exactly one /runs/{id}/end"
|
|
57
|
+
end_rid, end_payload = http.run_end[0]
|
|
58
|
+
assert end_rid == rid
|
|
59
|
+
assert end_payload["status"] == "ok"
|
|
60
|
+
assert "calls" in end_payload["output"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_multiple_join_runs_share_run_id(http):
|
|
64
|
+
proc_a = TrodoSpanProcessor(http_client=http)
|
|
65
|
+
proc_b = TrodoSpanProcessor(http_client=http)
|
|
66
|
+
|
|
67
|
+
rid = start_run(processor=proc_a, agent_name="session")
|
|
68
|
+
|
|
69
|
+
# Three tool calls from three different worker processes.
|
|
70
|
+
for i, tool in enumerate(["tool.a", "tool.b", "tool.c"]):
|
|
71
|
+
with join_run(
|
|
72
|
+
processor=proc_b,
|
|
73
|
+
team_site_id="site-x",
|
|
74
|
+
run_id=rid,
|
|
75
|
+
name=tool,
|
|
76
|
+
kind="tool",
|
|
77
|
+
) as span:
|
|
78
|
+
span.set_attribute("call_idx", i)
|
|
79
|
+
|
|
80
|
+
end_run(rid, processor=proc_a)
|
|
81
|
+
|
|
82
|
+
# All three spans hit /runs/{id}/spans with the same run_id.
|
|
83
|
+
assert len(http.spans_append) == 3
|
|
84
|
+
for sent_rid, spans in http.spans_append:
|
|
85
|
+
assert sent_rid == rid
|
|
86
|
+
assert spans[0]["run_id"] == rid
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for trodo.otel.wrap_agent.end_run module function."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from trodo.otel.processor import TrodoSpan
|
|
5
|
+
from trodo.otel.wrap_agent import start_run, end_run
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _make_span(run_id: str, *, kind: str = "tool", status: str = "ok",
|
|
9
|
+
input_tokens: int = 10, output_tokens: int = 20, cost: float = 0.001):
|
|
10
|
+
return TrodoSpan(
|
|
11
|
+
span_id="span-1",
|
|
12
|
+
run_id=run_id,
|
|
13
|
+
parent_span_id=None,
|
|
14
|
+
kind=kind,
|
|
15
|
+
name="step",
|
|
16
|
+
status=status,
|
|
17
|
+
input_tokens=input_tokens,
|
|
18
|
+
output_tokens=output_tokens,
|
|
19
|
+
cost=cost,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_posts_to_runs_end_endpoint(processor, http):
|
|
24
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
25
|
+
end_run(rid, processor=processor, output={"done": True}, status="ok")
|
|
26
|
+
|
|
27
|
+
assert len(http.run_end) == 1
|
|
28
|
+
sent_run_id, payload = http.run_end[0]
|
|
29
|
+
assert sent_run_id == rid
|
|
30
|
+
assert payload["status"] == "ok"
|
|
31
|
+
assert "ended_at" in payload
|
|
32
|
+
assert "done" in payload["output"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_aggregates_pending_spans(processor, http):
|
|
36
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
37
|
+
# Inject two completed spans into the local buffer (bypass joined-flush
|
|
38
|
+
# by directly populating; the real path goes through enqueue_span which
|
|
39
|
+
# already flushes for joined runs).
|
|
40
|
+
processor._pending[rid] = [
|
|
41
|
+
_make_span(rid, kind="tool", input_tokens=5, output_tokens=10, cost=0.001),
|
|
42
|
+
_make_span(rid, kind="llm", input_tokens=15, output_tokens=30, cost=0.002),
|
|
43
|
+
]
|
|
44
|
+
end_run(rid, processor=processor)
|
|
45
|
+
|
|
46
|
+
_, payload = http.run_end[0]
|
|
47
|
+
assert payload["total_tokens_in"] == 20
|
|
48
|
+
assert payload["total_tokens_out"] == 40
|
|
49
|
+
assert round(payload["total_cost"], 6) == 0.003
|
|
50
|
+
assert payload["span_count"] == 2
|
|
51
|
+
assert payload["tool_count"] == 1
|
|
52
|
+
assert payload["error_count"] == 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_records_error_status(processor, http):
|
|
56
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
57
|
+
end_run(rid, processor=processor, status="error", error_summary="boom")
|
|
58
|
+
_, payload = http.run_end[0]
|
|
59
|
+
assert payload["status"] == "error"
|
|
60
|
+
assert payload["error_summary"] == "boom"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unmarks_joined(processor, http):
|
|
64
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
65
|
+
assert rid in processor._joined_runs
|
|
66
|
+
end_run(rid, processor=processor)
|
|
67
|
+
assert rid not in processor._joined_runs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_clears_pending_after_end(processor, http):
|
|
71
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
72
|
+
processor._pending[rid] = [_make_span(rid)]
|
|
73
|
+
end_run(rid, processor=processor)
|
|
74
|
+
# unmark_joined() drops the bucket.
|
|
75
|
+
assert processor._pending.get(rid, []) == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_tolerates_http_failure(processor, http):
|
|
79
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
80
|
+
http.fail_next = True
|
|
81
|
+
end_run(rid, processor=processor)
|
|
82
|
+
# No exception; but the POST didn't record.
|
|
83
|
+
assert len(http.run_end) == 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_metadata_in_end_payload(processor, http):
|
|
87
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
88
|
+
end_run(rid, processor=processor, metadata={"closed_via": "session_timeout"})
|
|
89
|
+
_, payload = http.run_end[0]
|
|
90
|
+
assert payload["metadata"] == {"closed_via": "session_timeout"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Direct unit tests of TrodoSpanProcessor.start_run / .end_run."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from trodo.otel.processor import TrodoRun
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_start_run_posts_to_runs_start(processor, http):
|
|
8
|
+
run = TrodoRun(run_id="r1", agent_name="x", status="running")
|
|
9
|
+
processor.start_run(run)
|
|
10
|
+
assert len(http.run_start) == 1
|
|
11
|
+
assert http.run_start[0]["run"]["run_id"] == "r1"
|
|
12
|
+
assert http.run_start[0]["run"]["status"] == "running"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_end_run_posts_to_runs_end(processor, http):
|
|
16
|
+
processor.end_run("r1", {"status": "ok", "ended_at": "2026-04-30T00:00:00Z"})
|
|
17
|
+
assert len(http.run_end) == 1
|
|
18
|
+
rid, payload = http.run_end[0]
|
|
19
|
+
assert rid == "r1"
|
|
20
|
+
assert payload["status"] == "ok"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_start_run_swallows_http_errors(processor, http):
|
|
24
|
+
http.fail_next = True
|
|
25
|
+
run = TrodoRun(run_id="r1", agent_name="x")
|
|
26
|
+
# Must not raise.
|
|
27
|
+
processor.start_run(run)
|
|
28
|
+
assert len(http.run_start) == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_end_run_swallows_http_errors(processor, http):
|
|
32
|
+
http.fail_next = True
|
|
33
|
+
processor.end_run("r1", {"status": "ok"})
|
|
34
|
+
assert len(http.run_end) == 0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for trodo.otel.wrap_agent.start_run module function."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from trodo.otel.wrap_agent import start_run, end_run
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_mints_run_id_when_not_supplied(processor, http):
|
|
14
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
15
|
+
assert UUID_RE.match(rid)
|
|
16
|
+
assert len(http.run_start) == 1
|
|
17
|
+
assert http.run_start[0]["run"]["run_id"] == rid
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_accepts_caller_supplied_run_id(processor, http):
|
|
21
|
+
given = "11111111-2222-3333-4444-555555555555"
|
|
22
|
+
rid = start_run(processor=processor, agent_name="session", run_id=given)
|
|
23
|
+
assert rid == given
|
|
24
|
+
assert http.run_start[0]["run"]["run_id"] == given
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_posts_to_runs_start_with_full_payload(processor, http):
|
|
28
|
+
rid = start_run(
|
|
29
|
+
processor=processor,
|
|
30
|
+
agent_name="external_mcp_session",
|
|
31
|
+
distinct_id="user-7",
|
|
32
|
+
conversation_id="conv-abc",
|
|
33
|
+
parent_run_id="parent-1",
|
|
34
|
+
metadata={"mcp_client": "claude-desktop"},
|
|
35
|
+
input={"hello": "world"},
|
|
36
|
+
)
|
|
37
|
+
assert len(http.run_start) == 1
|
|
38
|
+
run = http.run_start[0]["run"]
|
|
39
|
+
assert run["run_id"] == rid
|
|
40
|
+
assert run["agent_name"] == "external_mcp_session"
|
|
41
|
+
assert run["distinct_id"] == "user-7"
|
|
42
|
+
assert run["conversation_id"] == "conv-abc"
|
|
43
|
+
assert run["parent_run_id"] == "parent-1"
|
|
44
|
+
assert run["status"] == "running"
|
|
45
|
+
assert run["metadata"] == {"mcp_client": "claude-desktop"}
|
|
46
|
+
assert "started_at" in run
|
|
47
|
+
# input is JSON-stringified inside _truncate
|
|
48
|
+
assert "hello" in run["input"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_marks_joined_locally(processor, http):
|
|
52
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
53
|
+
# Internal state — verify processor flips into joined mode.
|
|
54
|
+
assert rid in processor._joined_runs
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_tolerates_http_failure(processor, http):
|
|
58
|
+
"""start_run must not raise on backend failure — telemetry is fire-and-forget."""
|
|
59
|
+
http.fail_next = True
|
|
60
|
+
rid = start_run(processor=processor, agent_name="session")
|
|
61
|
+
assert UUID_RE.match(rid)
|
|
62
|
+
# Failed POST means nothing recorded, but no exception bubbled up.
|
|
63
|
+
assert len(http.run_start) == 0
|
|
64
|
+
# Local state still flipped (caller can still emit spans optimistically).
|
|
65
|
+
assert rid in processor._joined_runs
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Regression tests: wrap_agent's existing behaviour must not change.
|
|
2
|
+
|
|
3
|
+
The new start_run/end_run are additive — wrap_agent should still POST
|
|
4
|
+
/runs/ingest in one shot for the simple in-process case, NOT decompose
|
|
5
|
+
into start+end (which would double the HTTP traffic).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from trodo.otel.wrap_agent import wrap_agent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_wrap_agent_uses_single_ingest_call(processor, http):
|
|
15
|
+
with wrap_agent(
|
|
16
|
+
processor=processor,
|
|
17
|
+
team_site_id="site-x",
|
|
18
|
+
agent_name="chat",
|
|
19
|
+
distinct_id="user-1",
|
|
20
|
+
conversation_id="conv-1",
|
|
21
|
+
) as run:
|
|
22
|
+
run.set_input({"q": "hello"})
|
|
23
|
+
run.set_output({"a": "world"})
|
|
24
|
+
|
|
25
|
+
assert len(http.run_ingest) == 1, "wrap_agent must POST /runs/ingest exactly once"
|
|
26
|
+
assert len(http.run_start) == 0, "wrap_agent must NOT call /runs/start"
|
|
27
|
+
assert len(http.run_end) == 0, "wrap_agent must NOT call /runs/end"
|
|
28
|
+
|
|
29
|
+
run_payload = http.run_ingest[0]["run"]
|
|
30
|
+
assert run_payload["agent_name"] == "chat"
|
|
31
|
+
assert run_payload["distinct_id"] == "user-1"
|
|
32
|
+
assert run_payload["conversation_id"] == "conv-1"
|
|
33
|
+
assert run_payload["status"] == "ok"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_wrap_agent_records_error_on_exception(processor, http):
|
|
37
|
+
with pytest.raises(ValueError):
|
|
38
|
+
with wrap_agent(
|
|
39
|
+
processor=processor,
|
|
40
|
+
team_site_id="site-x",
|
|
41
|
+
agent_name="chat",
|
|
42
|
+
) as run:
|
|
43
|
+
run.set_input("query")
|
|
44
|
+
raise ValueError("kaboom")
|
|
45
|
+
|
|
46
|
+
assert len(http.run_ingest) == 1
|
|
47
|
+
run_payload = http.run_ingest[0]["run"]
|
|
48
|
+
assert run_payload["status"] == "error"
|
|
49
|
+
assert "kaboom" in run_payload["error_summary"]
|
|
@@ -40,7 +40,7 @@ Downstream microservice (join the caller's run instead of making a new one):
|
|
|
40
40
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
|
-
__version__ = "2.
|
|
43
|
+
__version__ = "2.2.0"
|
|
44
44
|
|
|
45
45
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
46
46
|
|
|
@@ -85,6 +85,8 @@ __all__ = [
|
|
|
85
85
|
"llm",
|
|
86
86
|
"retrieval",
|
|
87
87
|
"join_run",
|
|
88
|
+
"start_run",
|
|
89
|
+
"end_run",
|
|
88
90
|
"track_llm_call",
|
|
89
91
|
"feedback",
|
|
90
92
|
"get_tracer",
|
|
@@ -264,6 +266,52 @@ def join_run(
|
|
|
264
266
|
)
|
|
265
267
|
|
|
266
268
|
|
|
269
|
+
def start_run(
|
|
270
|
+
agent_name: str,
|
|
271
|
+
*,
|
|
272
|
+
run_id: Optional[str] = None,
|
|
273
|
+
distinct_id: Optional[str] = None,
|
|
274
|
+
conversation_id: Optional[str] = None,
|
|
275
|
+
parent_run_id: Optional[str] = None,
|
|
276
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
277
|
+
input: Any = None,
|
|
278
|
+
) -> str:
|
|
279
|
+
"""Open a Run record without a context manager.
|
|
280
|
+
|
|
281
|
+
Pairs with :func:`end_run` for sessions that span multiple processes or
|
|
282
|
+
HTTP requests. Returns the run_id (caller-supplied or freshly minted).
|
|
283
|
+
Between start_run and end_run any process can use ``join_run(run_id, ...)``
|
|
284
|
+
to add spans.
|
|
285
|
+
"""
|
|
286
|
+
return _get_client().start_run(
|
|
287
|
+
agent_name,
|
|
288
|
+
run_id=run_id,
|
|
289
|
+
distinct_id=distinct_id,
|
|
290
|
+
conversation_id=conversation_id,
|
|
291
|
+
parent_run_id=parent_run_id,
|
|
292
|
+
metadata=metadata,
|
|
293
|
+
input=input,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def end_run(
|
|
298
|
+
run_id: str,
|
|
299
|
+
*,
|
|
300
|
+
output: Any = None,
|
|
301
|
+
status: str = "ok",
|
|
302
|
+
error_summary: Optional[str] = None,
|
|
303
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Finalise a Run opened by :func:`start_run`."""
|
|
306
|
+
_get_client().end_run(
|
|
307
|
+
run_id,
|
|
308
|
+
output=output,
|
|
309
|
+
status=status,
|
|
310
|
+
error_summary=error_summary,
|
|
311
|
+
metadata=metadata,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
267
315
|
def tool(
|
|
268
316
|
name: Any = None,
|
|
269
317
|
fn: Optional[Callable[..., Any]] = None,
|
|
@@ -17,6 +17,8 @@ from .otel.wrap_agent import (
|
|
|
17
17
|
wrap_agent as wrap_agent_ctx,
|
|
18
18
|
span as span_ctx,
|
|
19
19
|
join_run as join_run_ctx,
|
|
20
|
+
start_run as start_run_fn,
|
|
21
|
+
end_run as end_run_fn,
|
|
20
22
|
current_run_id as _current_run_id,
|
|
21
23
|
current_span_id as _current_span_id,
|
|
22
24
|
)
|
|
@@ -284,6 +286,51 @@ class TrodoClient:
|
|
|
284
286
|
"""Create a nested span inside the current run."""
|
|
285
287
|
return span_ctx(name=name, kind=kind, input=input, attributes=attributes)
|
|
286
288
|
|
|
289
|
+
def start_run(
|
|
290
|
+
self,
|
|
291
|
+
agent_name: str,
|
|
292
|
+
*,
|
|
293
|
+
run_id: Optional[str] = None,
|
|
294
|
+
distinct_id: Optional[str] = None,
|
|
295
|
+
conversation_id: Optional[str] = None,
|
|
296
|
+
parent_run_id: Optional[str] = None,
|
|
297
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
298
|
+
input: Any = None,
|
|
299
|
+
) -> str:
|
|
300
|
+
"""Open a Run record outside a context manager. Returns the run_id.
|
|
301
|
+
|
|
302
|
+
Use ``end_run`` to finalise, ``join_run`` from any process to add spans.
|
|
303
|
+
"""
|
|
304
|
+
return start_run_fn(
|
|
305
|
+
processor=self._span_processor,
|
|
306
|
+
agent_name=agent_name,
|
|
307
|
+
run_id=run_id,
|
|
308
|
+
distinct_id=distinct_id,
|
|
309
|
+
conversation_id=conversation_id,
|
|
310
|
+
parent_run_id=parent_run_id,
|
|
311
|
+
metadata=metadata,
|
|
312
|
+
input=input,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def end_run(
|
|
316
|
+
self,
|
|
317
|
+
run_id: str,
|
|
318
|
+
*,
|
|
319
|
+
output: Any = None,
|
|
320
|
+
status: str = "ok",
|
|
321
|
+
error_summary: Optional[str] = None,
|
|
322
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Finalise a Run previously opened by :meth:`start_run`."""
|
|
325
|
+
end_run_fn(
|
|
326
|
+
run_id,
|
|
327
|
+
processor=self._span_processor,
|
|
328
|
+
output=output,
|
|
329
|
+
status=status,
|
|
330
|
+
error_summary=error_summary,
|
|
331
|
+
metadata=metadata,
|
|
332
|
+
)
|
|
333
|
+
|
|
287
334
|
def join_run(
|
|
288
335
|
self,
|
|
289
336
|
run_id: str,
|
|
@@ -136,6 +136,25 @@ class TrodoSpanProcessor:
|
|
|
136
136
|
except Exception:
|
|
137
137
|
pass
|
|
138
138
|
|
|
139
|
+
def start_run(self, run: TrodoRun) -> None:
|
|
140
|
+
"""Open a Run row server-side without holding a context manager.
|
|
141
|
+
|
|
142
|
+
Pairs with ``end_run`` for sessions that span multiple processes or
|
|
143
|
+
HTTP requests. Spans emitted between start_run and end_run flush
|
|
144
|
+
incrementally via append_spans (callers are expected to mark_joined).
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
self._http.post_run_start({"run": run.to_dict()})
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def end_run(self, run_id: str, payload: Dict[str, Any]) -> None:
|
|
152
|
+
"""Finalise a Run opened by start_run."""
|
|
153
|
+
try:
|
|
154
|
+
self._http.post_run_end(run_id, payload)
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
139
158
|
def append_spans(self, run_id: str, spans: List[TrodoSpan]) -> None:
|
|
140
159
|
"""Stream spans for a long-running or joined run without finalising."""
|
|
141
160
|
if not spans:
|
|
@@ -163,6 +163,76 @@ class SpanHandle:
|
|
|
163
163
|
self.tool_name = tool_name
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def start_run(
|
|
167
|
+
*,
|
|
168
|
+
processor: TrodoSpanProcessor,
|
|
169
|
+
agent_name: str,
|
|
170
|
+
run_id: Optional[str] = None,
|
|
171
|
+
distinct_id: Optional[str] = None,
|
|
172
|
+
conversation_id: Optional[str] = None,
|
|
173
|
+
parent_run_id: Optional[str] = None,
|
|
174
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
175
|
+
input: Any = None,
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Open a Run record without holding a context manager.
|
|
178
|
+
|
|
179
|
+
Pairs with :func:`end_run` for sessions that span multiple processes or
|
|
180
|
+
HTTP requests (e.g. an MCP server where ``initialize`` opens a run and
|
|
181
|
+
later ``tools/call`` requests append spans before a final close).
|
|
182
|
+
|
|
183
|
+
Returns the ``run_id`` (caller-supplied or freshly minted UUID). Between
|
|
184
|
+
``start_run`` and ``end_run`` any process can use ``join_run(run_id, ...)``
|
|
185
|
+
to add spans — they flush incrementally via ``append_spans``.
|
|
186
|
+
"""
|
|
187
|
+
rid = run_id or str(uuid.uuid4())
|
|
188
|
+
run = TrodoRun(
|
|
189
|
+
run_id=rid,
|
|
190
|
+
agent_name=agent_name,
|
|
191
|
+
distinct_id=distinct_id,
|
|
192
|
+
conversation_id=conversation_id,
|
|
193
|
+
parent_run_id=parent_run_id,
|
|
194
|
+
status="running",
|
|
195
|
+
input=_truncate(input),
|
|
196
|
+
started_at=_now_iso(),
|
|
197
|
+
metadata=metadata,
|
|
198
|
+
)
|
|
199
|
+
processor.mark_joined(rid)
|
|
200
|
+
processor.start_run(run)
|
|
201
|
+
return rid
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def end_run(
|
|
205
|
+
run_id: str,
|
|
206
|
+
*,
|
|
207
|
+
processor: TrodoSpanProcessor,
|
|
208
|
+
output: Any = None,
|
|
209
|
+
status: str = "ok",
|
|
210
|
+
error_summary: Optional[str] = None,
|
|
211
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Finalise a Run opened by :func:`start_run`.
|
|
214
|
+
|
|
215
|
+
Aggregates any locally-buffered spans for ``run_id``, POSTs to the
|
|
216
|
+
``/runs/{id}/end`` endpoint, and unmarks the run as joined. Idempotent
|
|
217
|
+
on the local-state side; the backend treats a second call as a row update.
|
|
218
|
+
"""
|
|
219
|
+
pending = processor.get_pending(run_id)
|
|
220
|
+
agg = _aggregate(pending)
|
|
221
|
+
payload: Dict[str, Any] = {
|
|
222
|
+
"ended_at": _now_iso(),
|
|
223
|
+
"status": status,
|
|
224
|
+
**agg,
|
|
225
|
+
}
|
|
226
|
+
if output is not None:
|
|
227
|
+
payload["output"] = _truncate(output)
|
|
228
|
+
if error_summary is not None:
|
|
229
|
+
payload["error_summary"] = _truncate(error_summary, 4_000)
|
|
230
|
+
if metadata is not None:
|
|
231
|
+
payload["metadata"] = metadata
|
|
232
|
+
processor.end_run(run_id, payload)
|
|
233
|
+
processor.unmark_joined(run_id)
|
|
234
|
+
|
|
235
|
+
|
|
166
236
|
class wrap_agent:
|
|
167
237
|
"""Context manager wrapping an agent run.
|
|
168
238
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trodo-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.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
|
|
@@ -263,6 +263,34 @@ with trodo.join_run(
|
|
|
263
263
|
...
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
+
### Long-lived sessions across processes — `start_run` / `end_run`
|
|
267
|
+
|
|
268
|
+
`wrap_agent` is a context manager — it opens *and* closes the run in one
|
|
269
|
+
call stack. For sessions that live across many HTTP requests (an MCP
|
|
270
|
+
server, a websocket-pinned chat, scheduled jobs that resume on different
|
|
271
|
+
workers), use `start_run` to open the run from one process and `end_run`
|
|
272
|
+
to finalise it later. Between the two, any process can use `join_run` to
|
|
273
|
+
add child spans. Same `run_id` threads through everything.
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
# Process A — open the run for an MCP session.
|
|
277
|
+
run_id = trodo.start_run(
|
|
278
|
+
'external_mcp_session',
|
|
279
|
+
distinct_id=str(user_id),
|
|
280
|
+
conversation_id=mcp_session_id,
|
|
281
|
+
)
|
|
282
|
+
redis.set(f"mcp:run:{mcp_session_id}", run_id, ex=3600)
|
|
283
|
+
|
|
284
|
+
# Process B (later, possibly a different worker) — append a tool span.
|
|
285
|
+
run_id = redis.get(f"mcp:run:{mcp_session_id}").decode()
|
|
286
|
+
with trodo.join_run(run_id, name='tool.run_funnel_query', kind='tool') as span:
|
|
287
|
+
span.set_input(args)
|
|
288
|
+
span.set_output(result)
|
|
289
|
+
|
|
290
|
+
# When the session ends (timeout sweeper, explicit close):
|
|
291
|
+
trodo.end_run(run_id, status='ok')
|
|
292
|
+
```
|
|
293
|
+
|
|
266
294
|
### Conversation binding & feedback
|
|
267
295
|
|
|
268
296
|
```python
|
|
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
|