trodo-python 1.0.1__py3-none-any.whl → 2.1.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,32 +1,71 @@
1
1
  """
2
2
  trodo-python — Trodo Analytics SDK for Python
3
3
 
4
- Usage (module-level singleton):
4
+ Quick start (any stack):
5
5
  import trodo
6
- trodo.init(site_id='your-site-id')
7
- user = trodo.for_user('user-123')
8
- user.track('purchase_completed', {'amount': 99.99})
9
-
10
- Usage (class):
11
- from trodo import TrodoClient
12
- client = TrodoClient(site_id='your-site-id')
13
- user = client.for_user('user-123')
14
- user.track('purchase_completed', {'amount': 99.99})
6
+ trodo.init(site_id='your-site-id') # auto-instrument on by default
7
+
8
+ # Anthropic / OpenAI / LangChain / LlamaIndex — nothing else to do.
9
+ with trodo.wrap_agent('customer-support',
10
+ distinct_id=uid, conversation_id=conv) as run:
11
+ result = agent.run(query)
12
+ run.set_output(result)
13
+
14
+ trodo.feedback(run.run_id, satisfaction='positive', rating=5)
15
+
16
+ Raw-HTTP LLM caller (no OTel integration for your client):
17
+ resp = httpx.post(..., json=body).json()
18
+ trodo.track_llm_call(
19
+ model='gemini-2.5-flash', provider='google',
20
+ input_tokens=resp['usageMetadata']['promptTokenCount'],
21
+ output_tokens=resp['usageMetadata']['candidatesTokenCount'],
22
+ prompt=body, completion=resp,
23
+ )
24
+
25
+ Custom tool:
26
+ @trodo.tool()
27
+ def run_funnel_query(team_id, preset): ...
28
+
29
+ Downstream microservice (join the caller's run instead of making a new one):
30
+ # In FastAPI:
31
+ app.middleware('http')(trodo.fastapi_middleware())
32
+
33
+ # Or manually:
34
+ with trodo.join_run(
35
+ run_id=headers['X-Trodo-Run-Id'],
36
+ parent_span_id=headers['X-Trodo-Parent-Span-Id'],
37
+ ):
38
+ ...
15
39
  """
16
40
 
17
41
  from __future__ import annotations
18
42
 
19
- from typing import Any, Dict, List, Optional, Union
43
+ __version__ = "2.1.0"
44
+
45
+ from typing import Any, Callable, Dict, List, Optional, Union
20
46
 
21
47
  from .client import TrodoClient
22
48
  from .user_context import UserContext
23
49
  from .managers.group_manager import GroupProfile
24
- from .types import ApiResult, IdentifyResult, ResetResult, WalletAddressResult
50
+ from .otel.wrap_agent import (
51
+ wrap_agent as _wrap_agent_ctx,
52
+ span as _span_ctx,
53
+ join_run as _join_run_ctx,
54
+ RunHandle,
55
+ SpanHandle,
56
+ )
57
+ from .types import (
58
+ ApiResult, ResetResult, WalletAddressResult,
59
+ FeedbackProps,
60
+ )
25
61
 
26
62
  __all__ = [
27
63
  "TrodoClient",
28
64
  "UserContext",
29
65
  "GroupProfile",
66
+ "RunHandle",
67
+ "SpanHandle",
68
+ "FeedbackProps",
30
69
  "init",
31
70
  "for_user",
32
71
  "track",
@@ -37,6 +76,23 @@ __all__ = [
37
76
  "disable_auto_events",
38
77
  "flush",
39
78
  "shutdown",
79
+ # Agent runs
80
+ "wrap_agent",
81
+ "agent",
82
+ "span",
83
+ "tool",
84
+ "trace",
85
+ "llm",
86
+ "retrieval",
87
+ "join_run",
88
+ "track_llm_call",
89
+ "feedback",
90
+ "get_tracer",
91
+ # Cross-service propagation
92
+ "fastapi_middleware",
93
+ "propagation_headers",
94
+ "current_run_id",
95
+ "current_span_id",
40
96
  ]
41
97
 
42
98
  # ============================================================================
@@ -54,6 +110,10 @@ def _get_client() -> TrodoClient:
54
110
  return _client
55
111
 
56
112
 
113
+ def _maybe_client() -> Optional[TrodoClient]:
114
+ return _client
115
+
116
+
57
117
  def init(
58
118
  site_id: str,
59
119
  api_base: str = "https://sdkapi.trodo.ai",
@@ -65,8 +125,15 @@ def init(
65
125
  auto_events: bool = False,
66
126
  on_error: Optional[Any] = None,
67
127
  debug: bool = False,
128
+ auto_instrument: bool = True,
68
129
  ) -> TrodoClient:
69
- """Initialise the singleton SDK instance."""
130
+ """Initialise the singleton SDK instance.
131
+
132
+ ``auto_instrument=True`` (default) registers OTel adapters for
133
+ Anthropic, OpenAI, LangChain, LlamaIndex, Google Generative AI and
134
+ any other installed opentelemetry-instrumentation-* package, so
135
+ LLM calls emit token/cost spans with no further code.
136
+ """
70
137
  global _client
71
138
  _client = TrodoClient(
72
139
  site_id=site_id,
@@ -79,6 +146,7 @@ def init(
79
146
  auto_events=auto_events,
80
147
  on_error=on_error,
81
148
  debug=debug,
149
+ auto_instrument=auto_instrument,
82
150
  )
83
151
  return _client
84
152
 
@@ -87,7 +155,6 @@ def for_user(
87
155
  distinct_id: str,
88
156
  session_id: Optional[str] = None,
89
157
  ) -> UserContext:
90
- """Return a UserContext bound to the given distinctId."""
91
158
  return _get_client().for_user(distinct_id, session_id)
92
159
 
93
160
 
@@ -97,22 +164,18 @@ def track(
97
164
  properties: Optional[Dict[str, Any]] = None,
98
165
  category: str = "custom",
99
166
  ) -> None:
100
- """Track an event for a user (direct-call pattern)."""
101
167
  _get_client().track(distinct_id, event_name, properties, category)
102
168
 
103
169
 
104
- def identify(distinct_id: str, identify_id: str) -> IdentifyResult:
105
- """Alias a user's distinctId to an external identifier."""
106
- return _get_client().identify(distinct_id, identify_id)
170
+ def identify(identify_id: str, session_id: Optional[str] = None) -> UserContext:
171
+ return _get_client().identify(identify_id, session_id)
107
172
 
108
173
 
109
174
  def wallet_address(distinct_id: str, wallet_addr: str) -> WalletAddressResult:
110
- """Associate a wallet address with a user."""
111
175
  return _get_client().wallet_address(distinct_id, wallet_addr)
112
176
 
113
177
 
114
178
  def reset(distinct_id: str) -> ResetResult:
115
- """Reset a user's session."""
116
179
  return _get_client().reset(distinct_id)
117
180
 
118
181
 
@@ -125,10 +188,243 @@ def disable_auto_events() -> None:
125
188
 
126
189
 
127
190
  def flush() -> None:
128
- """Flush any queued batch events."""
129
191
  _get_client().flush()
130
192
 
131
193
 
132
194
  def shutdown() -> None:
133
- """Flush, stop timers, and disable auto events."""
134
195
  _get_client().shutdown()
196
+
197
+
198
+ # ----------------------------------------------------------------------------
199
+ # Agent Runs
200
+ # ----------------------------------------------------------------------------
201
+
202
+ def wrap_agent(
203
+ agent_name: str,
204
+ *,
205
+ distinct_id: Optional[str] = None,
206
+ conversation_id: Optional[str] = None,
207
+ parent_run_id: Optional[str] = None,
208
+ metadata: Optional[Dict[str, Any]] = None,
209
+ ) -> _wrap_agent_ctx:
210
+ """Wrap a block as an agent run — returns a context manager yielding RunHandle."""
211
+ return _get_client().wrap_agent(
212
+ agent_name,
213
+ distinct_id=distinct_id,
214
+ conversation_id=conversation_id,
215
+ parent_run_id=parent_run_id,
216
+ metadata=metadata,
217
+ )
218
+
219
+
220
+ def agent(
221
+ agent_name: str,
222
+ *,
223
+ distinct_id: Optional[str] = None,
224
+ conversation_id: Optional[str] = None,
225
+ metadata: Optional[Dict[str, Any]] = None,
226
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
227
+ """Decorator form of wrap_agent."""
228
+ return _get_client().agent(
229
+ agent_name,
230
+ distinct_id=distinct_id,
231
+ conversation_id=conversation_id,
232
+ metadata=metadata,
233
+ )
234
+
235
+
236
+ def span(
237
+ name: str,
238
+ *,
239
+ kind: str = "generic",
240
+ input: Any = None,
241
+ attributes: Optional[Dict[str, Any]] = None,
242
+ ) -> _span_ctx:
243
+ """Nested span inside the current run."""
244
+ return _get_client().span(name, kind=kind, input=input, attributes=attributes)
245
+
246
+
247
+ def join_run(
248
+ run_id: str,
249
+ parent_span_id: Optional[str] = None,
250
+ *,
251
+ name: str = "remote.handler",
252
+ kind: str = "agent",
253
+ input: Any = None,
254
+ attributes: Optional[Dict[str, Any]] = None,
255
+ ) -> _join_run_ctx:
256
+ """Open a span on an existing run owned by a remote service."""
257
+ return _get_client().join_run(
258
+ run_id,
259
+ parent_span_id,
260
+ name=name,
261
+ kind=kind,
262
+ input=input,
263
+ attributes=attributes,
264
+ )
265
+
266
+
267
+ def tool(
268
+ name: Any = None,
269
+ fn: Optional[Callable[..., Any]] = None,
270
+ *,
271
+ kind: str = "tool",
272
+ ) -> Any:
273
+ """Wrap a function as a tool span — dual-form helper and decorator.
274
+
275
+ Helper form::
276
+
277
+ run_funnel_query = trodo.tool('run_funnel_query', run_funnel_query)
278
+
279
+ Decorator form (backward-compatible)::
280
+
281
+ @trodo.tool()
282
+ def run_funnel_query(team_id, preset): ...
283
+
284
+ @trodo.tool(name='custom-name')
285
+ async def fetch(...): ...
286
+ """
287
+ # Deferred import: allow @trodo.tool() at import-time before init().
288
+ from .otel.helpers import tool as _tool_helper
289
+ return _tool_helper(name, fn, kind=kind)
290
+
291
+
292
+ def trace(
293
+ name: Any = None,
294
+ fn: Optional[Callable[..., Any]] = None,
295
+ ) -> Any:
296
+ """Wrap a function as a generic span — dual-form helper and decorator."""
297
+ from .otel.helpers import trace as _trace_helper
298
+ return _trace_helper(name, fn)
299
+
300
+
301
+ def llm(
302
+ name: Any = None,
303
+ fn: Optional[Callable[..., Any]] = None,
304
+ *,
305
+ model: Optional[str] = None,
306
+ provider: Optional[str] = None,
307
+ temperature: Optional[float] = None,
308
+ extract_usage: Optional[Callable[[Any], Any]] = None,
309
+ ) -> Any:
310
+ """Wrap an LLM call — auto-captures tokens from OpenAI/Anthropic/Gemini
311
+ response shapes. Dual-form helper and decorator."""
312
+ from .otel.helpers import llm as _llm_helper
313
+ return _llm_helper(
314
+ name,
315
+ fn,
316
+ model=model,
317
+ provider=provider,
318
+ temperature=temperature,
319
+ extract_usage=extract_usage,
320
+ )
321
+
322
+
323
+ def retrieval(
324
+ name: Any = None,
325
+ fn: Optional[Callable[..., Any]] = None,
326
+ ) -> Any:
327
+ """Wrap a retriever / vector search as a kind='retrieval' span."""
328
+ from .otel.helpers import retrieval as _retrieval_helper
329
+ return _retrieval_helper(name, fn)
330
+
331
+
332
+ def get_tracer(name: str = "trodo") -> Any:
333
+ """Return a raw OTel tracer — the Trodo processor is already subscribed.
334
+
335
+ Use for advanced cases where you want to emit spans with the raw OTel
336
+ API; they'll be captured and forwarded to Trodo like any other span.
337
+
338
+ tracer = trodo.get_tracer('my.module')
339
+ with tracer.start_as_current_span('custom') as sp:
340
+ sp.set_attribute('foo', 'bar')
341
+ """
342
+ try:
343
+ from opentelemetry import trace as _otel_trace # type: ignore
344
+ except Exception as e: # pragma: no cover
345
+ raise RuntimeError(
346
+ "trodo.get_tracer requires the opentelemetry-api package"
347
+ ) from e
348
+ return _otel_trace.get_tracer(name)
349
+
350
+
351
+ def track_llm_call(
352
+ *,
353
+ model: Optional[str] = None,
354
+ provider: Optional[str] = None,
355
+ input_tokens: Optional[int] = None,
356
+ output_tokens: Optional[int] = None,
357
+ prompt: Any = None,
358
+ completion: Any = None,
359
+ temperature: Optional[float] = None,
360
+ cost: Optional[float] = None,
361
+ name: Optional[str] = None,
362
+ metadata: Optional[Dict[str, Any]] = None,
363
+ ) -> None:
364
+ """Record a one-shot LLM span for a raw-HTTP caller."""
365
+ from .otel.helpers import track_llm_call as _fn
366
+ _fn(
367
+ model=model,
368
+ provider=provider,
369
+ input_tokens=input_tokens,
370
+ output_tokens=output_tokens,
371
+ prompt=prompt,
372
+ completion=completion,
373
+ temperature=temperature,
374
+ cost=cost,
375
+ name=name,
376
+ metadata=metadata,
377
+ )
378
+
379
+
380
+ def feedback(
381
+ run_id: str,
382
+ *,
383
+ satisfaction: Optional[str] = None,
384
+ rating: Optional[float] = None,
385
+ comment: Optional[str] = None,
386
+ feedback: Optional[str] = None,
387
+ distinct_id: Optional[str] = None,
388
+ metadata: Optional[Dict[str, Any]] = None,
389
+ ) -> ApiResult:
390
+ """Attach feedback to a completed run."""
391
+ return _get_client().feedback(
392
+ run_id,
393
+ satisfaction=satisfaction,
394
+ rating=rating,
395
+ comment=comment,
396
+ feedback=feedback,
397
+ distinct_id=distinct_id,
398
+ metadata=metadata,
399
+ )
400
+
401
+
402
+ # ----------------------------------------------------------------------------
403
+ # Cross-service propagation
404
+ # ----------------------------------------------------------------------------
405
+
406
+ def fastapi_middleware() -> Callable:
407
+ """Return a FastAPI/Starlette middleware that auto-joins inbound runs.
408
+
409
+ Usage:
410
+ app = FastAPI()
411
+ trodo.init(site_id='...')
412
+ app.middleware('http')(trodo.fastapi_middleware())
413
+ """
414
+ return _get_client().fastapi_middleware()
415
+
416
+
417
+ def propagation_headers() -> Dict[str, str]:
418
+ """Return outbound HTTP headers carrying the current run/span id."""
419
+ from .otel.helpers import propagation_headers as _fn
420
+ return _fn()
421
+
422
+
423
+ def current_run_id() -> Optional[str]:
424
+ from .otel.wrap_agent import current_run_id as _fn
425
+ return _fn()
426
+
427
+
428
+ def current_span_id() -> Optional[str]:
429
+ from .otel.wrap_agent import current_span_id as _fn
430
+ return _fn()
trodo/api/endpoints.py CHANGED
@@ -18,3 +18,8 @@ GROUPS_SET = "/api/sdk/groups/set_group"
18
18
  GROUPS_ADD = "/api/sdk/groups/add_group"
19
19
  GROUPS_REMOVE = "/api/sdk/groups/remove_group"
20
20
  GROUPS_PROFILE = "/api/sdk/groups/profile"
21
+ # Agent run tracking (Lemma-style, run-centric)
22
+ RUNS_INGEST = "/api/sdk/runs/ingest"
23
+ RUNS_START = "/api/sdk/runs/start"
24
+ RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
25
+ OTLP_TRACES = "/api/sdk/otel/v1/traces"
trodo/api/http_client.py CHANGED
@@ -85,3 +85,34 @@ class HttpClient:
85
85
 
86
86
  def post_group(self, path: str, payload: Dict[str, Any]) -> ApiResult:
87
87
  return self._request(path, payload)
88
+
89
+ # ------------------------------------------------------------------
90
+ # Agent Runs (Lemma-style)
91
+ # ------------------------------------------------------------------
92
+
93
+ def post_run_ingest(self, payload: Dict[str, Any]) -> ApiResult:
94
+ return self._request("/api/sdk/runs/ingest", payload)
95
+
96
+ def post_run_start(self, payload: Dict[str, Any]) -> ApiResult:
97
+ return self._request("/api/sdk/runs/start", payload)
98
+
99
+ def post_run_end(self, run_id: str, payload: Dict[str, Any]) -> ApiResult:
100
+ from urllib.parse import quote
101
+ return self._request(f"/api/sdk/runs/{quote(run_id, safe='')}/end", payload)
102
+
103
+ def post_spans_append(self, run_id: str, spans: list) -> ApiResult:
104
+ from urllib.parse import quote
105
+ return self._request(
106
+ f"/api/sdk/runs/{quote(run_id, safe='')}/spans",
107
+ {"spans": spans},
108
+ )
109
+
110
+ def post_run_feedback(self, run_id: str, payload: Dict[str, Any]) -> ApiResult:
111
+ from urllib.parse import quote
112
+ return self._request(
113
+ f"/api/sdk/runs/{quote(run_id, safe='')}/feedback",
114
+ payload,
115
+ )
116
+
117
+ def post_otlp_traces(self, payload: Dict[str, Any]) -> ApiResult:
118
+ return self._request("/api/sdk/otel/v1/traces", payload)