trodo-python 1.2.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.
Files changed (41) hide show
  1. {trodo_python-1.2.0 → trodo_python-2.2.0}/PKG-INFO +183 -3
  2. {trodo_python-1.2.0 → trodo_python-2.2.0}/README.md +182 -2
  3. {trodo_python-1.2.0 → trodo_python-2.2.0}/pyproject.toml +1 -1
  4. trodo_python-2.2.0/tests/test_cross_process_session.py +86 -0
  5. trodo_python-2.2.0/tests/test_end_run.py +90 -0
  6. trodo_python-2.2.0/tests/test_processor_methods.py +34 -0
  7. trodo_python-2.2.0/tests/test_start_run.py +65 -0
  8. trodo_python-2.2.0/tests/test_wrap_agent_unchanged.py +49 -0
  9. trodo_python-2.2.0/trodo/__init__.py +478 -0
  10. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/endpoints.py +5 -1
  11. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/http_client.py +30 -2
  12. trodo_python-2.2.0/trodo/client.py +446 -0
  13. trodo_python-2.2.0/trodo/otel/__init__.py +21 -0
  14. trodo_python-2.2.0/trodo/otel/auto_instrument.py +281 -0
  15. trodo_python-2.2.0/trodo/otel/context.py +44 -0
  16. trodo_python-2.2.0/trodo/otel/helpers.py +407 -0
  17. trodo_python-2.2.0/trodo/otel/processor.py +184 -0
  18. trodo_python-2.2.0/trodo/otel/wrap_agent.py +508 -0
  19. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/types.py +12 -68
  20. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/PKG-INFO +183 -3
  21. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/SOURCES.txt +11 -0
  22. trodo_python-1.2.0/trodo/__init__.py +0 -177
  23. trodo_python-1.2.0/trodo/client.py +0 -318
  24. {trodo_python-1.2.0 → trodo_python-2.2.0}/setup.cfg +0 -0
  25. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/__init__.py +0 -0
  26. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/async_client.py +0 -0
  27. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/auto/__init__.py +0 -0
  28. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/auto/auto_event_manager.py +0 -0
  29. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/managers/__init__.py +0 -0
  30. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/managers/group_manager.py +0 -0
  31. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/managers/people_manager.py +0 -0
  32. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/queue/__init__.py +0 -0
  33. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/queue/batch_flusher.py +0 -0
  34. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/queue/event_queue.py +0 -0
  35. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/session/__init__.py +0 -0
  36. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/session/server_session.py +0 -0
  37. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/session/session_manager.py +0 -0
  38. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/user_context.py +0 -0
  39. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  40. {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/requires.txt +0 -0
  41. {trodo_python-1.2.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: 1.2.0
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
@@ -136,9 +136,188 @@ trodo.set_group('user-123', 'company', 'acme')
136
136
 
137
137
  ---
138
138
 
139
- ## Agent Analytics
139
+ ## AI Agent Tracing (recommended)
140
140
 
141
- Track every step of your LLM agents. Each call counts as one event toward your plan limit.
141
+ One wrap around your agent captures every LLM call, tool call, and
142
+ nested step as a tree of spans — token counts, costs, inputs, outputs,
143
+ errors. Works with any stack: OpenAI, Anthropic, LangChain, LlamaIndex,
144
+ Gemini, raw HTTP, custom tools. Cost is derived server-side from
145
+ `(provider, model)` — the SDK only sends tokens.
146
+
147
+ ### 30-second quickstart
148
+
149
+ ```python
150
+ import trodo
151
+ trodo.init(site_id='your-site-id') # auto-instrument on by default
152
+
153
+ with trodo.wrap_agent('customer-support',
154
+ distinct_id=user_id,
155
+ conversation_id=session_id) as run:
156
+ run.set_input({'query': 'where did sales drop'})
157
+ answer = agent.run(query) # OpenAI/Anthropic/LangChain auto-captured
158
+ run.set_output(answer)
159
+ ```
160
+
161
+ Open the Agent Runs dashboard — the row shows tokens in/out, cost,
162
+ span count, tool count, error count, plus the full trace tree.
163
+
164
+ ### Auto-instrumentation
165
+
166
+ `trodo.init()` calls `enable_auto_instrument()` which registers every
167
+ installed OpenTelemetry instrumentor. No extra code required.
168
+
169
+ | Framework | Install |
170
+ |-----------|---------|
171
+ | OpenAI | `pip install opentelemetry-instrumentation-openai` |
172
+ | Anthropic | `pip install opentelemetry-instrumentation-anthropic` |
173
+ | LangChain | `pip install opentelemetry-instrumentation-langchain` |
174
+ | LlamaIndex | `pip install opentelemetry-instrumentation-llama-index` |
175
+ | Google Gemini | `pip install opentelemetry-instrumentation-google-generativeai` |
176
+ | Vertex AI | `pip install opentelemetry-instrumentation-vertexai` |
177
+ | Bedrock | `pip install opentelemetry-instrumentation-bedrock` |
178
+ | Cohere | `pip install opentelemetry-instrumentation-cohere` |
179
+ | Mistral | `pip install opentelemetry-instrumentation-mistralai` |
180
+ | Haystack | `pip install opentelemetry-instrumentation-haystack` |
181
+ | httpx / requests | bundled — generic HTTP spans for raw-HTTP callers |
182
+
183
+ Opt out with `trodo.init(site_id=..., auto_instrument=False)`.
184
+
185
+ ### Span helpers
186
+
187
+ Typed function wrappers for custom code — every call becomes a span
188
+ with the args auto-captured as `input`, return value as `output`,
189
+ exception as `error`. Dual-form: helper **and** decorator.
190
+
191
+ ```python
192
+ # trace — generic span
193
+ prepared = trodo.trace('prepare', prepare_fn)(payload)
194
+
195
+ @trodo.trace('step')
196
+ def step(): ...
197
+
198
+ # tool — tool span (auto tool_name, kind='tool')
199
+ run_funnel = trodo.tool('run_funnel_query', run_funnel_query)
200
+ result = run_funnel(team_id=1, preset='day7')
201
+
202
+ @trodo.tool(name='fetch_user')
203
+ async def fetch_user(uid): ...
204
+
205
+ # llm — LLM span, auto-extracts OpenAI / Anthropic / Gemini usage
206
+ answer = trodo.llm(
207
+ 'answer', call_openai, model='gpt-4o-mini', provider='openai',
208
+ )(messages)
209
+ # Records input_tokens / output_tokens from response['usage'].
210
+
211
+ # retrieval — vector search / RAG retriever span
212
+ search = trodo.retrieval('vector_search', vector_search)
213
+ docs = search(query)
214
+ ```
215
+
216
+ ### Raw-HTTP escape hatches
217
+
218
+ If your LLM client isn't OTel-instrumented and you can't wrap it as a
219
+ function, record a span post-hoc:
220
+
221
+ ```python
222
+ resp = httpx.post(url, json=body).json()
223
+ trodo.track_llm_call(
224
+ model='gemini-2.5-flash', provider='google',
225
+ input_tokens=resp['usageMetadata']['promptTokenCount'],
226
+ output_tokens=resp['usageMetadata']['candidatesTokenCount'],
227
+ prompt=body, completion=resp,
228
+ )
229
+ ```
230
+
231
+ For advanced cases, get a raw OTel tracer — the Trodo processor is
232
+ already subscribed:
233
+
234
+ ```python
235
+ tracer = trodo.get_tracer('my.module')
236
+ with tracer.start_as_current_span('custom') as sp:
237
+ sp.set_attribute('gen_ai.system', 'my-llm')
238
+ ```
239
+
240
+ ### Cross-service runs
241
+
242
+ When one service calls another, the downstream service **joins** the
243
+ caller's run instead of creating its own. All spans nest under a single
244
+ timeline in the dashboard.
245
+
246
+ ```python
247
+ # Caller (FastAPI / Flask / Django / …) — outbound:
248
+ import httpx
249
+ httpx.post(url, headers=trodo.propagation_headers(), json=body)
250
+
251
+ # Downstream (FastAPI):
252
+ from fastapi import FastAPI
253
+ app = FastAPI()
254
+ app.middleware('http')(trodo.fastapi_middleware())
255
+ # Every LLM call / @tool / trace helper inside handlers now nests under
256
+ # the caller's run — no extra wiring.
257
+
258
+ # Or manually:
259
+ with trodo.join_run(
260
+ run_id=headers['x-trodo-run-id'],
261
+ parent_span_id=headers['x-trodo-parent-span-id'],
262
+ ):
263
+ ...
264
+ ```
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
+
294
+ ### Conversation binding & feedback
295
+
296
+ ```python
297
+ with trodo.wrap_agent(
298
+ 'chat', distinct_id=user_id, conversation_id=session_id,
299
+ ) as run:
300
+ ...
301
+ # Later:
302
+ trodo.feedback(run.run_id, satisfaction='positive', rating=5)
303
+ ```
304
+
305
+ ### Cookbook
306
+
307
+ Runnable scenarios that double as integration tests live in
308
+ `sandbox/scenarios/` — `span_helpers.py`, `raw_http.py`,
309
+ `custom_tools.py`, `cross_service.py`, `concurrent_100.py`,
310
+ `long_run.py`, plus opt-in `openai_auto.py`, `anthropic_auto.py`,
311
+ `langchain_chain.py`. Run them with
312
+ `python -m sandbox.run_all` from the SDK root.
313
+
314
+ ---
315
+
316
+ ## Agent Analytics (legacy event-based API)
317
+
318
+ The older per-event API below is still supported but superseded by
319
+ `wrap_agent` + span helpers above. Use it only if you're already wired
320
+ into it; new integrations should prefer the tracing API.
142
321
 
143
322
  **Before you start:** register your agent in **Integrations → AI Agents** in the dashboard to get an `agent_id` (`agt_xxxxxxxx`).
144
323
 
@@ -161,6 +340,7 @@ trodo.track_agent_call(AgentCallProps(
161
340
  provider='anthropic',
162
341
  system_prompt_version='v2', # optional — track prompt iterations
163
342
  distinct_id=user_id, # optional — link to a Trodo user
343
+ metadata={'thread_source': 'slack', 'locale': 'en'}, # optional — agent_calls.metadata JSONB
164
344
  ))
165
345
  ```
166
346
 
@@ -109,9 +109,188 @@ trodo.set_group('user-123', 'company', 'acme')
109
109
 
110
110
  ---
111
111
 
112
- ## Agent Analytics
112
+ ## AI Agent Tracing (recommended)
113
113
 
114
- Track every step of your LLM agents. Each call counts as one event toward your plan limit.
114
+ One wrap around your agent captures every LLM call, tool call, and
115
+ nested step as a tree of spans — token counts, costs, inputs, outputs,
116
+ errors. Works with any stack: OpenAI, Anthropic, LangChain, LlamaIndex,
117
+ Gemini, raw HTTP, custom tools. Cost is derived server-side from
118
+ `(provider, model)` — the SDK only sends tokens.
119
+
120
+ ### 30-second quickstart
121
+
122
+ ```python
123
+ import trodo
124
+ trodo.init(site_id='your-site-id') # auto-instrument on by default
125
+
126
+ with trodo.wrap_agent('customer-support',
127
+ distinct_id=user_id,
128
+ conversation_id=session_id) as run:
129
+ run.set_input({'query': 'where did sales drop'})
130
+ answer = agent.run(query) # OpenAI/Anthropic/LangChain auto-captured
131
+ run.set_output(answer)
132
+ ```
133
+
134
+ Open the Agent Runs dashboard — the row shows tokens in/out, cost,
135
+ span count, tool count, error count, plus the full trace tree.
136
+
137
+ ### Auto-instrumentation
138
+
139
+ `trodo.init()` calls `enable_auto_instrument()` which registers every
140
+ installed OpenTelemetry instrumentor. No extra code required.
141
+
142
+ | Framework | Install |
143
+ |-----------|---------|
144
+ | OpenAI | `pip install opentelemetry-instrumentation-openai` |
145
+ | Anthropic | `pip install opentelemetry-instrumentation-anthropic` |
146
+ | LangChain | `pip install opentelemetry-instrumentation-langchain` |
147
+ | LlamaIndex | `pip install opentelemetry-instrumentation-llama-index` |
148
+ | Google Gemini | `pip install opentelemetry-instrumentation-google-generativeai` |
149
+ | Vertex AI | `pip install opentelemetry-instrumentation-vertexai` |
150
+ | Bedrock | `pip install opentelemetry-instrumentation-bedrock` |
151
+ | Cohere | `pip install opentelemetry-instrumentation-cohere` |
152
+ | Mistral | `pip install opentelemetry-instrumentation-mistralai` |
153
+ | Haystack | `pip install opentelemetry-instrumentation-haystack` |
154
+ | httpx / requests | bundled — generic HTTP spans for raw-HTTP callers |
155
+
156
+ Opt out with `trodo.init(site_id=..., auto_instrument=False)`.
157
+
158
+ ### Span helpers
159
+
160
+ Typed function wrappers for custom code — every call becomes a span
161
+ with the args auto-captured as `input`, return value as `output`,
162
+ exception as `error`. Dual-form: helper **and** decorator.
163
+
164
+ ```python
165
+ # trace — generic span
166
+ prepared = trodo.trace('prepare', prepare_fn)(payload)
167
+
168
+ @trodo.trace('step')
169
+ def step(): ...
170
+
171
+ # tool — tool span (auto tool_name, kind='tool')
172
+ run_funnel = trodo.tool('run_funnel_query', run_funnel_query)
173
+ result = run_funnel(team_id=1, preset='day7')
174
+
175
+ @trodo.tool(name='fetch_user')
176
+ async def fetch_user(uid): ...
177
+
178
+ # llm — LLM span, auto-extracts OpenAI / Anthropic / Gemini usage
179
+ answer = trodo.llm(
180
+ 'answer', call_openai, model='gpt-4o-mini', provider='openai',
181
+ )(messages)
182
+ # Records input_tokens / output_tokens from response['usage'].
183
+
184
+ # retrieval — vector search / RAG retriever span
185
+ search = trodo.retrieval('vector_search', vector_search)
186
+ docs = search(query)
187
+ ```
188
+
189
+ ### Raw-HTTP escape hatches
190
+
191
+ If your LLM client isn't OTel-instrumented and you can't wrap it as a
192
+ function, record a span post-hoc:
193
+
194
+ ```python
195
+ resp = httpx.post(url, json=body).json()
196
+ trodo.track_llm_call(
197
+ model='gemini-2.5-flash', provider='google',
198
+ input_tokens=resp['usageMetadata']['promptTokenCount'],
199
+ output_tokens=resp['usageMetadata']['candidatesTokenCount'],
200
+ prompt=body, completion=resp,
201
+ )
202
+ ```
203
+
204
+ For advanced cases, get a raw OTel tracer — the Trodo processor is
205
+ already subscribed:
206
+
207
+ ```python
208
+ tracer = trodo.get_tracer('my.module')
209
+ with tracer.start_as_current_span('custom') as sp:
210
+ sp.set_attribute('gen_ai.system', 'my-llm')
211
+ ```
212
+
213
+ ### Cross-service runs
214
+
215
+ When one service calls another, the downstream service **joins** the
216
+ caller's run instead of creating its own. All spans nest under a single
217
+ timeline in the dashboard.
218
+
219
+ ```python
220
+ # Caller (FastAPI / Flask / Django / …) — outbound:
221
+ import httpx
222
+ httpx.post(url, headers=trodo.propagation_headers(), json=body)
223
+
224
+ # Downstream (FastAPI):
225
+ from fastapi import FastAPI
226
+ app = FastAPI()
227
+ app.middleware('http')(trodo.fastapi_middleware())
228
+ # Every LLM call / @tool / trace helper inside handlers now nests under
229
+ # the caller's run — no extra wiring.
230
+
231
+ # Or manually:
232
+ with trodo.join_run(
233
+ run_id=headers['x-trodo-run-id'],
234
+ parent_span_id=headers['x-trodo-parent-span-id'],
235
+ ):
236
+ ...
237
+ ```
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
+
267
+ ### Conversation binding & feedback
268
+
269
+ ```python
270
+ with trodo.wrap_agent(
271
+ 'chat', distinct_id=user_id, conversation_id=session_id,
272
+ ) as run:
273
+ ...
274
+ # Later:
275
+ trodo.feedback(run.run_id, satisfaction='positive', rating=5)
276
+ ```
277
+
278
+ ### Cookbook
279
+
280
+ Runnable scenarios that double as integration tests live in
281
+ `sandbox/scenarios/` — `span_helpers.py`, `raw_http.py`,
282
+ `custom_tools.py`, `cross_service.py`, `concurrent_100.py`,
283
+ `long_run.py`, plus opt-in `openai_auto.py`, `anthropic_auto.py`,
284
+ `langchain_chain.py`. Run them with
285
+ `python -m sandbox.run_all` from the SDK root.
286
+
287
+ ---
288
+
289
+ ## Agent Analytics (legacy event-based API)
290
+
291
+ The older per-event API below is still supported but superseded by
292
+ `wrap_agent` + span helpers above. Use it only if you're already wired
293
+ into it; new integrations should prefer the tracing API.
115
294
 
116
295
  **Before you start:** register your agent in **Integrations → AI Agents** in the dashboard to get an `agent_id` (`agt_xxxxxxxx`).
117
296
 
@@ -134,6 +313,7 @@ trodo.track_agent_call(AgentCallProps(
134
313
  provider='anthropic',
135
314
  system_prompt_version='v2', # optional — track prompt iterations
136
315
  distinct_id=user_id, # optional — link to a Trodo user
316
+ metadata={'thread_source': 'slack', 'locale': 'en'}, # optional — agent_calls.metadata JSONB
137
317
  ))
138
318
  ```
139
319
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "1.2.0"
7
+ version = "2.2.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