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.
- {trodo_python-1.2.0 → trodo_python-2.2.0}/PKG-INFO +183 -3
- {trodo_python-1.2.0 → trodo_python-2.2.0}/README.md +182 -2
- {trodo_python-1.2.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.2.0/trodo/__init__.py +478 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/endpoints.py +5 -1
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/http_client.py +30 -2
- trodo_python-2.2.0/trodo/client.py +446 -0
- trodo_python-2.2.0/trodo/otel/__init__.py +21 -0
- trodo_python-2.2.0/trodo/otel/auto_instrument.py +281 -0
- trodo_python-2.2.0/trodo/otel/context.py +44 -0
- trodo_python-2.2.0/trodo/otel/helpers.py +407 -0
- trodo_python-2.2.0/trodo/otel/processor.py +184 -0
- trodo_python-2.2.0/trodo/otel/wrap_agent.py +508 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/types.py +12 -68
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/PKG-INFO +183 -3
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/SOURCES.txt +11 -0
- trodo_python-1.2.0/trodo/__init__.py +0 -177
- trodo_python-1.2.0/trodo/client.py +0 -318
- {trodo_python-1.2.0 → trodo_python-2.2.0}/setup.cfg +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/__init__.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/api/async_client.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/session/__init__.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/session/server_session.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo/user_context.py +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-1.2.0 → trodo_python-2.2.0}/trodo_python.egg-info/requires.txt +0 -0
- {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:
|
|
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
|
|
139
|
+
## AI Agent Tracing (recommended)
|
|
140
140
|
|
|
141
|
-
|
|
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
|
|
112
|
+
## AI Agent Tracing (recommended)
|
|
113
113
|
|
|
114
|
-
|
|
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
|
|
|
@@ -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
|