trodo-python 2.1.0__tar.gz → 2.3.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 (39) hide show
  1. {trodo_python-2.1.0 → trodo_python-2.3.0}/PKG-INFO +29 -1
  2. {trodo_python-2.1.0 → trodo_python-2.3.0}/README.md +28 -0
  3. {trodo_python-2.1.0 → trodo_python-2.3.0}/pyproject.toml +1 -1
  4. trodo_python-2.3.0/tests/test_cross_process_session.py +86 -0
  5. trodo_python-2.3.0/tests/test_end_run.py +90 -0
  6. trodo_python-2.3.0/tests/test_processor_methods.py +34 -0
  7. trodo_python-2.3.0/tests/test_start_run.py +65 -0
  8. trodo_python-2.3.0/tests/test_wrap_agent_unchanged.py +49 -0
  9. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/__init__.py +109 -1
  10. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/api/http_client.py +5 -0
  11. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/client.py +107 -0
  12. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/otel/helpers.py +90 -0
  13. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/otel/processor.py +19 -0
  14. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/otel/wrap_agent.py +70 -0
  15. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo_python.egg-info/PKG-INFO +29 -1
  16. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo_python.egg-info/SOURCES.txt +5 -0
  17. {trodo_python-2.1.0 → trodo_python-2.3.0}/setup.cfg +0 -0
  18. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/api/__init__.py +0 -0
  19. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/api/async_client.py +0 -0
  20. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/api/endpoints.py +0 -0
  21. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/auto/__init__.py +0 -0
  22. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/auto/auto_event_manager.py +0 -0
  23. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/managers/__init__.py +0 -0
  24. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/managers/group_manager.py +0 -0
  25. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/managers/people_manager.py +0 -0
  26. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/otel/__init__.py +0 -0
  27. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/otel/auto_instrument.py +0 -0
  28. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/otel/context.py +0 -0
  29. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/queue/__init__.py +0 -0
  30. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/queue/batch_flusher.py +0 -0
  31. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/queue/event_queue.py +0 -0
  32. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/session/__init__.py +0 -0
  33. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/session/server_session.py +0 -0
  34. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/session/session_manager.py +0 -0
  35. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/types.py +0 -0
  36. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo/user_context.py +0 -0
  37. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  38. {trodo_python-2.1.0 → trodo_python-2.3.0}/trodo_python.egg-info/requires.txt +0 -0
  39. {trodo_python-2.1.0 → trodo_python-2.3.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.1.0
3
+ Version: 2.3.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.1.0"
7
+ version = "2.3.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,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.1.0"
43
+ __version__ = "2.3.0"
44
44
 
45
45
  from typing import Any, Callable, Dict, List, Optional, Union
46
46
 
@@ -85,7 +85,10 @@ __all__ = [
85
85
  "llm",
86
86
  "retrieval",
87
87
  "join_run",
88
+ "start_run",
89
+ "end_run",
88
90
  "track_llm_call",
91
+ "track_mcp",
89
92
  "feedback",
90
93
  "get_tracer",
91
94
  # Cross-service propagation
@@ -264,6 +267,52 @@ def join_run(
264
267
  )
265
268
 
266
269
 
270
+ def start_run(
271
+ agent_name: str,
272
+ *,
273
+ run_id: Optional[str] = None,
274
+ distinct_id: Optional[str] = None,
275
+ conversation_id: Optional[str] = None,
276
+ parent_run_id: Optional[str] = None,
277
+ metadata: Optional[Dict[str, Any]] = None,
278
+ input: Any = None,
279
+ ) -> str:
280
+ """Open a Run record without a context manager.
281
+
282
+ Pairs with :func:`end_run` for sessions that span multiple processes or
283
+ HTTP requests. Returns the run_id (caller-supplied or freshly minted).
284
+ Between start_run and end_run any process can use ``join_run(run_id, ...)``
285
+ to add spans.
286
+ """
287
+ return _get_client().start_run(
288
+ agent_name,
289
+ run_id=run_id,
290
+ distinct_id=distinct_id,
291
+ conversation_id=conversation_id,
292
+ parent_run_id=parent_run_id,
293
+ metadata=metadata,
294
+ input=input,
295
+ )
296
+
297
+
298
+ def end_run(
299
+ run_id: str,
300
+ *,
301
+ output: Any = None,
302
+ status: str = "ok",
303
+ error_summary: Optional[str] = None,
304
+ metadata: Optional[Dict[str, Any]] = None,
305
+ ) -> None:
306
+ """Finalise a Run opened by :func:`start_run`."""
307
+ _get_client().end_run(
308
+ run_id,
309
+ output=output,
310
+ status=status,
311
+ error_summary=error_summary,
312
+ metadata=metadata,
313
+ )
314
+
315
+
267
316
  def tool(
268
317
  name: Any = None,
269
318
  fn: Optional[Callable[..., Any]] = None,
@@ -377,6 +426,65 @@ def track_llm_call(
377
426
  )
378
427
 
379
428
 
429
+ def track_mcp(
430
+ *,
431
+ tool: str,
432
+ distinct_id: str,
433
+ input: Any = None,
434
+ output: Any = None,
435
+ error: Optional[str] = None,
436
+ duration_ms: Optional[int] = None,
437
+ session_id: Optional[str] = None,
438
+ client_label: Optional[str] = None,
439
+ attributes: Optional[Dict[str, Any]] = None,
440
+ agent_name: str = "MCP",
441
+ started_at: Optional[str] = None,
442
+ ended_at: Optional[str] = None,
443
+ ) -> str:
444
+ """Record one MCP tool call as a runless span (no parent run).
445
+
446
+ Use this from inside an MCP server's ``tools/call`` handler. The MCP
447
+ server proxies tool calls but never sees the user's prompt or the
448
+ LLM's final answer, so a parent run carries no analytical value.
449
+ Each call to ``track_mcp`` writes one self-contained span row tagged
450
+ with ``agent_name='MCP'`` (overridable), ``distinct_id`` (the user),
451
+ and ``conversation_id`` (the MCP-Session-Id, auto-uuid'd if omitted).
452
+
453
+ Required:
454
+ tool: the tool name. Becomes ``name='tool.<tool>'``.
455
+ distinct_id: end-user attribution (email or stable user id).
456
+
457
+ Optional:
458
+ input, output: full args + result. Truncated at 64KB by the SDK.
459
+ error: if set, marks ``status='error'``.
460
+ duration_ms: tool wall time. Defaults to 0 if omitted.
461
+ session_id: the ``Mcp-Session-Id`` from the client; auto-uuid'd
462
+ if omitted (each call becomes its own grouping bucket).
463
+ client_label: 'anthropic' / 'cursor' / 'chatgpt' / etc.; merged
464
+ into attributes as ``mcp_client_label``.
465
+ attributes: free-form extras.
466
+ agent_name: defaults to 'MCP'; override only for custom tags.
467
+ started_at / ended_at: ISO 8601; defaults derived from now() and
468
+ ``duration_ms`` so callers usually skip them.
469
+
470
+ Returns the span_id (str) for caller-side correlation/logging.
471
+ """
472
+ return _get_client().track_mcp(
473
+ tool=tool,
474
+ distinct_id=distinct_id,
475
+ input=input,
476
+ output=output,
477
+ error=error,
478
+ duration_ms=duration_ms,
479
+ session_id=session_id,
480
+ client_label=client_label,
481
+ attributes=attributes,
482
+ agent_name=agent_name,
483
+ started_at=started_at,
484
+ ended_at=ended_at,
485
+ )
486
+
487
+
380
488
  def feedback(
381
489
  run_id: str,
382
490
  *,
@@ -107,6 +107,11 @@ class HttpClient:
107
107
  {"spans": spans},
108
108
  )
109
109
 
110
+ def post_runless_spans(self, spans: list) -> ApiResult:
111
+ """Ingest spans with no parent run (e.g. MCP tool calls). Each span
112
+ MUST carry distinct_id and agent_name; the server gates on that."""
113
+ return self._request("/api/sdk/spans/append", {"spans": spans})
114
+
110
115
  def post_run_feedback(self, run_id: str, payload: Dict[str, Any]) -> ApiResult:
111
116
  from urllib.parse import quote
112
117
  return self._request(
@@ -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
  )
@@ -24,6 +26,7 @@ from .otel.auto_instrument import enable_auto_instrument
24
26
  from .otel.helpers import (
25
27
  tool as tool_decorator,
26
28
  track_llm_call as track_llm_call_fn,
29
+ track_mcp as track_mcp_fn,
27
30
  fastapi_middleware as fastapi_middleware_fn,
28
31
  propagation_headers as propagation_headers_fn,
29
32
  )
@@ -284,6 +287,51 @@ class TrodoClient:
284
287
  """Create a nested span inside the current run."""
285
288
  return span_ctx(name=name, kind=kind, input=input, attributes=attributes)
286
289
 
290
+ def start_run(
291
+ self,
292
+ agent_name: str,
293
+ *,
294
+ run_id: Optional[str] = None,
295
+ distinct_id: Optional[str] = None,
296
+ conversation_id: Optional[str] = None,
297
+ parent_run_id: Optional[str] = None,
298
+ metadata: Optional[Dict[str, Any]] = None,
299
+ input: Any = None,
300
+ ) -> str:
301
+ """Open a Run record outside a context manager. Returns the run_id.
302
+
303
+ Use ``end_run`` to finalise, ``join_run`` from any process to add spans.
304
+ """
305
+ return start_run_fn(
306
+ processor=self._span_processor,
307
+ agent_name=agent_name,
308
+ run_id=run_id,
309
+ distinct_id=distinct_id,
310
+ conversation_id=conversation_id,
311
+ parent_run_id=parent_run_id,
312
+ metadata=metadata,
313
+ input=input,
314
+ )
315
+
316
+ def end_run(
317
+ self,
318
+ run_id: str,
319
+ *,
320
+ output: Any = None,
321
+ status: str = "ok",
322
+ error_summary: Optional[str] = None,
323
+ metadata: Optional[Dict[str, Any]] = None,
324
+ ) -> None:
325
+ """Finalise a Run previously opened by :meth:`start_run`."""
326
+ end_run_fn(
327
+ run_id,
328
+ processor=self._span_processor,
329
+ output=output,
330
+ status=status,
331
+ error_summary=error_summary,
332
+ metadata=metadata,
333
+ )
334
+
287
335
  def join_run(
288
336
  self,
289
337
  run_id: str,
@@ -348,6 +396,65 @@ class TrodoClient:
348
396
  metadata=metadata,
349
397
  )
350
398
 
399
+ def track_mcp(
400
+ self,
401
+ *,
402
+ tool: str,
403
+ distinct_id: str,
404
+ input: Any = None,
405
+ output: Any = None,
406
+ error: Optional[str] = None,
407
+ duration_ms: Optional[int] = None,
408
+ session_id: Optional[str] = None,
409
+ client_label: Optional[str] = None,
410
+ attributes: Optional[Dict[str, Any]] = None,
411
+ agent_name: str = "MCP",
412
+ started_at: Optional[str] = None,
413
+ ended_at: Optional[str] = None,
414
+ ) -> str:
415
+ """Record a single MCP tool call as a runless span.
416
+
417
+ Use this from inside an MCP server's ``tools/call`` handler — one
418
+ call to ``track_mcp`` = one row in ``agent_spans`` with ``run_id``
419
+ NULL. The MCP server proxies tool calls but never sees the user's
420
+ prompt or the LLM's final answer, so a parent run carries no
421
+ analytical value; the span is the unit of analytics.
422
+
423
+ Required:
424
+ tool: the tool name (becomes ``name = "tool.<tool>"``).
425
+ distinct_id: end-user attribution (email or stable user id).
426
+
427
+ Optional but recommended:
428
+ input / output: the tool's args + full result. Truncated at 64KB.
429
+ error: if set, marks ``status="error"`` and stored as
430
+ ``error_message``.
431
+ duration_ms: tool wall time. If omitted, falls back to 0.
432
+ session_id: the ``Mcp-Session-Id`` from the client; stored as
433
+ ``conversation_id`` for grouping. Auto-uuid'd if
434
+ omitted (each call becomes its own bucket).
435
+ client_label: which MCP client (anthropic / cursor / chatgpt /
436
+ etc.); merged into attributes.
437
+ attributes: any extra free-form key/values.
438
+ agent_name: defaults to "MCP"; override only for custom tags.
439
+
440
+ Returns the span_id (str) so the caller can correlate.
441
+ """
442
+ return track_mcp_fn(
443
+ http=self._http,
444
+ tool=tool,
445
+ distinct_id=distinct_id,
446
+ input=input,
447
+ output=output,
448
+ error=error,
449
+ duration_ms=duration_ms,
450
+ session_id=session_id,
451
+ client_label=client_label,
452
+ attributes=attributes,
453
+ agent_name=agent_name,
454
+ started_at=started_at,
455
+ ended_at=ended_at,
456
+ )
457
+
351
458
  def fastapi_middleware(self) -> Callable:
352
459
  """Return a FastAPI/Starlette middleware that auto-joins runs."""
353
460
  return fastapi_middleware_fn(self)
@@ -286,6 +286,96 @@ def llm(
286
286
  )
287
287
 
288
288
 
289
+ def track_mcp(
290
+ *,
291
+ http: Any,
292
+ tool: str,
293
+ distinct_id: str,
294
+ input: Any = None,
295
+ output: Any = None,
296
+ error: Optional[str] = None,
297
+ duration_ms: Optional[int] = None,
298
+ session_id: Optional[str] = None,
299
+ client_label: Optional[str] = None,
300
+ attributes: Optional[Dict[str, Any]] = None,
301
+ agent_name: str = "MCP",
302
+ started_at: Optional[str] = None,
303
+ ended_at: Optional[str] = None,
304
+ ) -> str:
305
+ """Record one MCP tool call as a runless span. See ``Client.track_mcp``
306
+ for the customer-facing docstring; this is the implementation that
307
+ builds the span payload and posts it via the HttpClient.
308
+
309
+ Returns the span_id.
310
+ """
311
+ import json as _json
312
+ import uuid as _uuid
313
+ from datetime import datetime, timedelta, timezone
314
+
315
+ if not tool:
316
+ raise ValueError("track_mcp: 'tool' is required")
317
+ if not distinct_id:
318
+ raise ValueError("track_mcp: 'distinct_id' is required (runless spans must attribute)")
319
+
320
+ span_id = str(_uuid.uuid4())
321
+ conv_id = session_id or str(_uuid.uuid4())
322
+
323
+ now = datetime.now(timezone.utc)
324
+ if ended_at is None:
325
+ ended_at = now.isoformat()
326
+ 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
331
+
332
+ status = "error" if error else "ok"
333
+ if error:
334
+ error_payload: Any = {"error": error}
335
+ output_to_record = output if output is not None else error_payload
336
+ else:
337
+ output_to_record = output
338
+
339
+ def _stringify(value: Any) -> Any:
340
+ if value is None or isinstance(value, str):
341
+ return value
342
+ try:
343
+ return _json.dumps(value, default=str)
344
+ except Exception:
345
+ return str(value)
346
+
347
+ merged_attributes: Dict[str, Any] = dict(attributes or {})
348
+ if client_label:
349
+ merged_attributes.setdefault("mcp_client_label", client_label)
350
+
351
+ span_payload: Dict[str, Any] = {
352
+ "span_id": span_id,
353
+ "kind": "tool",
354
+ "name": f"tool.{tool}",
355
+ "status": status,
356
+ "input": _stringify({"tool": tool, "params": input}) if input is not None else None,
357
+ "output": _stringify(output_to_record),
358
+ "tool_name": tool,
359
+ "started_at": started_at,
360
+ "ended_at": ended_at,
361
+ "duration_ms": duration_ms,
362
+ "distinct_id": distinct_id,
363
+ "conversation_id": conv_id,
364
+ "agent_name": agent_name,
365
+ "error_message": error,
366
+ "attributes": merged_attributes,
367
+ }
368
+
369
+ try:
370
+ http.post_runless_spans([span_payload])
371
+ except Exception:
372
+ # Span loss must never break the caller's request flow. The
373
+ # underlying HttpClient already retries; failures here mean the
374
+ # backend rejected (rare) or the network is permanently down.
375
+ pass
376
+ return span_id
377
+
378
+
289
379
  def track_llm_call(
290
380
  *,
291
381
  model: Optional[str] = None,
@@ -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.1.0
3
+ Version: 2.3.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
@@ -1,5 +1,10 @@
1
1
  README.md
2
2
  pyproject.toml
3
+ tests/test_cross_process_session.py
4
+ tests/test_end_run.py
5
+ tests/test_processor_methods.py
6
+ tests/test_start_run.py
7
+ tests/test_wrap_agent_unchanged.py
3
8
  trodo/__init__.py
4
9
  trodo/client.py
5
10
  trodo/types.py
File without changes