trodo-python 2.17.0__tar.gz → 2.18.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {trodo_python-2.17.0 → trodo_python-2.18.1}/PKG-INFO +57 -4
- {trodo_python-2.17.0 → trodo_python-2.18.1}/README.md +56 -3
- {trodo_python-2.17.0 → trodo_python-2.18.1}/pyproject.toml +1 -1
- trodo_python-2.18.1/tests/test_multi_agent_shape.py +106 -0
- trodo_python-2.18.1/tests/test_user_traits.py +278 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/__init__.py +54 -2
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/api/endpoints.py +2 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/api/http_client.py +12 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/client.py +61 -7
- trodo_python-2.18.1/trodo/managers/user_manager.py +70 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/processor.py +4 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/wrap_agent.py +31 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/session/session_manager.py +11 -5
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/user_context.py +8 -5
- trodo_python-2.18.1/trodo/util/__init__.py +1 -0
- trodo_python-2.18.1/trodo/util/lru.py +71 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo_python.egg-info/PKG-INFO +57 -4
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo_python.egg-info/SOURCES.txt +5 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/setup.cfg +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_datasets.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_end_run.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_experiments.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_prompt_trace.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_prompts.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_register_otel.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_start_run.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/api/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/api/async_client.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/managers/dataset_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/managers/experiment_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/managers/prompt_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/context.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/register.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/otel/transport.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/prompts/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/prompts/compile.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/prompts/template.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/prompts/types.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/session/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/session/server_session.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo/types.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.1}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trodo-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.18.1
|
|
4
4
|
Summary: Trodo Analytics SDK for Python — server-side event tracking
|
|
5
5
|
License: ISC
|
|
6
6
|
Keywords: analytics,tracking,trodo,server-side
|
|
@@ -123,7 +123,7 @@ Creates the session and fires `POST /api/sdk/identify`. Use to link a `distinct_
|
|
|
123
123
|
|
|
124
124
|
```python
|
|
125
125
|
user = trodo.identify('user@example.com', session_id=request.cookies.get('trodo_session'))
|
|
126
|
-
# distinct_id is now
|
|
126
|
+
# distinct_id is now user@example.com — merges with browser events
|
|
127
127
|
user.track('login')
|
|
128
128
|
```
|
|
129
129
|
|
|
@@ -171,6 +171,59 @@ trodo.people_set('user-123', {'plan': 'pro'})
|
|
|
171
171
|
trodo.set_group('user-123', 'company', 'acme')
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
### `upsert_user` — identity and traits in one call
|
|
175
|
+
|
|
176
|
+
Create-or-update a user and their traits idempotently. This is the call a
|
|
177
|
+
stateless request handler makes; safe to run on every request.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
result = trodo.upsert_user(
|
|
181
|
+
'user-42',
|
|
182
|
+
properties={'plan': 'pro', 'company': 'Acme'}, # merged
|
|
183
|
+
set_once={'signup_date': '2026-01-04'}, # fill-if-absent
|
|
184
|
+
fixed_properties={'last_location_country': 'India'},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
result['created'] # True if this call created the user
|
|
188
|
+
result['applied']['properties'] # ['plan', 'company']
|
|
189
|
+
result['skipped']['fixedProperties']
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
All three bags are optional. With none of them it still ensures the user
|
|
193
|
+
exists — what you want at signup, before they've produced any runs.
|
|
194
|
+
|
|
195
|
+
**Fixed vs custom properties.** `properties` / `set_once` are free-form and live
|
|
196
|
+
on the profile as custom properties. `fixed_properties` writes the real columns
|
|
197
|
+
the dashboard filters on, and only a backend-knowable subset is accepted:
|
|
198
|
+
|
|
199
|
+
| Settable from a backend | Browser-only |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `first_location_country`, `last_location_country` | `device_type`, `browser_name`, `os` |
|
|
202
|
+
| `first_location_city`, `last_location_city` | `first_referrer`, `last_referrer`, `utm_*` |
|
|
203
|
+
|
|
204
|
+
A browser-only key isn't an error — it comes back in `skipped` with
|
|
205
|
+
`reason: 'BROWSER_ONLY_PROPERTY'` and the rest of the batch still applies. The
|
|
206
|
+
reasoning: a backend supplying a browser name is guessing, and a wrong guess
|
|
207
|
+
silently corrupts attribution reporting.
|
|
208
|
+
|
|
209
|
+
Where both a browser and a backend write the same column, **the browser wins** —
|
|
210
|
+
it observed the value, the server supplied one — and the server write is
|
|
211
|
+
reported as `BROWSER_OWNED_PROPERTY`.
|
|
212
|
+
|
|
213
|
+
### Attributing and enriching in one call
|
|
214
|
+
|
|
215
|
+
`wrap_agent` and `start_run` take an optional `user=` bag, so a request handler
|
|
216
|
+
doesn't need a separate `upsert_user` call it has to sequence correctly:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
with trodo.wrap_agent('support-agent', distinct_id=user_id,
|
|
220
|
+
user={'properties': {'plan': 'pro'}}) as run:
|
|
221
|
+
...
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Applied server-side and non-fatally — a failed trait write never costs you the
|
|
225
|
+
trace. Use `upsert_user` directly when you need to know what landed.
|
|
226
|
+
|
|
174
227
|
---
|
|
175
228
|
|
|
176
229
|
## AI Agent Tracing (recommended)
|
|
@@ -617,10 +670,10 @@ Call `identify()` with the **same value** on the browser and server to merge all
|
|
|
617
670
|
|
|
618
671
|
```python
|
|
619
672
|
# Python
|
|
620
|
-
user.identify('user@example.com') # →
|
|
673
|
+
user.identify('user@example.com') # → user@example.com
|
|
621
674
|
|
|
622
675
|
# Browser (same value)
|
|
623
|
-
# Trodo.identify('user@example.com') →
|
|
676
|
+
# Trodo.identify('user@example.com') → user@example.com
|
|
624
677
|
# Events from both sides now appear together in the dashboard
|
|
625
678
|
```
|
|
626
679
|
|
|
@@ -92,7 +92,7 @@ Creates the session and fires `POST /api/sdk/identify`. Use to link a `distinct_
|
|
|
92
92
|
|
|
93
93
|
```python
|
|
94
94
|
user = trodo.identify('user@example.com', session_id=request.cookies.get('trodo_session'))
|
|
95
|
-
# distinct_id is now
|
|
95
|
+
# distinct_id is now user@example.com — merges with browser events
|
|
96
96
|
user.track('login')
|
|
97
97
|
```
|
|
98
98
|
|
|
@@ -140,6 +140,59 @@ trodo.people_set('user-123', {'plan': 'pro'})
|
|
|
140
140
|
trodo.set_group('user-123', 'company', 'acme')
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
### `upsert_user` — identity and traits in one call
|
|
144
|
+
|
|
145
|
+
Create-or-update a user and their traits idempotently. This is the call a
|
|
146
|
+
stateless request handler makes; safe to run on every request.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
result = trodo.upsert_user(
|
|
150
|
+
'user-42',
|
|
151
|
+
properties={'plan': 'pro', 'company': 'Acme'}, # merged
|
|
152
|
+
set_once={'signup_date': '2026-01-04'}, # fill-if-absent
|
|
153
|
+
fixed_properties={'last_location_country': 'India'},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
result['created'] # True if this call created the user
|
|
157
|
+
result['applied']['properties'] # ['plan', 'company']
|
|
158
|
+
result['skipped']['fixedProperties']
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
All three bags are optional. With none of them it still ensures the user
|
|
162
|
+
exists — what you want at signup, before they've produced any runs.
|
|
163
|
+
|
|
164
|
+
**Fixed vs custom properties.** `properties` / `set_once` are free-form and live
|
|
165
|
+
on the profile as custom properties. `fixed_properties` writes the real columns
|
|
166
|
+
the dashboard filters on, and only a backend-knowable subset is accepted:
|
|
167
|
+
|
|
168
|
+
| Settable from a backend | Browser-only |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `first_location_country`, `last_location_country` | `device_type`, `browser_name`, `os` |
|
|
171
|
+
| `first_location_city`, `last_location_city` | `first_referrer`, `last_referrer`, `utm_*` |
|
|
172
|
+
|
|
173
|
+
A browser-only key isn't an error — it comes back in `skipped` with
|
|
174
|
+
`reason: 'BROWSER_ONLY_PROPERTY'` and the rest of the batch still applies. The
|
|
175
|
+
reasoning: a backend supplying a browser name is guessing, and a wrong guess
|
|
176
|
+
silently corrupts attribution reporting.
|
|
177
|
+
|
|
178
|
+
Where both a browser and a backend write the same column, **the browser wins** —
|
|
179
|
+
it observed the value, the server supplied one — and the server write is
|
|
180
|
+
reported as `BROWSER_OWNED_PROPERTY`.
|
|
181
|
+
|
|
182
|
+
### Attributing and enriching in one call
|
|
183
|
+
|
|
184
|
+
`wrap_agent` and `start_run` take an optional `user=` bag, so a request handler
|
|
185
|
+
doesn't need a separate `upsert_user` call it has to sequence correctly:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
with trodo.wrap_agent('support-agent', distinct_id=user_id,
|
|
189
|
+
user={'properties': {'plan': 'pro'}}) as run:
|
|
190
|
+
...
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Applied server-side and non-fatally — a failed trait write never costs you the
|
|
194
|
+
trace. Use `upsert_user` directly when you need to know what landed.
|
|
195
|
+
|
|
143
196
|
---
|
|
144
197
|
|
|
145
198
|
## AI Agent Tracing (recommended)
|
|
@@ -586,10 +639,10 @@ Call `identify()` with the **same value** on the browser and server to merge all
|
|
|
586
639
|
|
|
587
640
|
```python
|
|
588
641
|
# Python
|
|
589
|
-
user.identify('user@example.com') # →
|
|
642
|
+
user.identify('user@example.com') # → user@example.com
|
|
590
643
|
|
|
591
644
|
# Browser (same value)
|
|
592
|
-
# Trodo.identify('user@example.com') →
|
|
645
|
+
# Trodo.identify('user@example.com') → user@example.com
|
|
593
646
|
# Events from both sides now appear together in the dashboard
|
|
594
647
|
```
|
|
595
648
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Multi-agent run shape — one run, sub-agents as ``agent``-kind child spans.
|
|
2
|
+
|
|
3
|
+
The shape the ``trodo-tracing`` skill teaches: a request that fans out to
|
|
4
|
+
sub-agents produces ONE run whose tree carries an ``agent`` span per sub-agent,
|
|
5
|
+
with each sub-agent's own work nested underneath it. The failure mode this
|
|
6
|
+
guards against is a ``wrap_agent`` per sub-agent, which shatters one request
|
|
7
|
+
into N disconnected runs and loses whole-request cost, latency and the
|
|
8
|
+
delegation tree.
|
|
9
|
+
|
|
10
|
+
Also covers the module-level ``user`` / ``parent_run_id`` kwargs, which the
|
|
11
|
+
skill's guidance writes and which the top-level wrappers used to silently drop.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import inspect
|
|
17
|
+
|
|
18
|
+
import trodo
|
|
19
|
+
from trodo.otel.wrap_agent import span as span_ctx
|
|
20
|
+
from trodo.otel.wrap_agent import wrap_agent as wrap_agent_ctx
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _tree(payload):
|
|
24
|
+
"""{span_id: (name, kind, parent_span_id)} for one ingested run payload."""
|
|
25
|
+
return {s["span_id"]: (s["name"], s["kind"], s["parent_span_id"]) for s in payload["spans"]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_sub_agents_nest_as_agent_spans_in_one_run(processor, http):
|
|
29
|
+
"""A supervisor with two workers and a writer = 1 run, 3 agent spans."""
|
|
30
|
+
with wrap_agent_ctx(
|
|
31
|
+
processor=processor, team_site_id="site", agent_name="research_assistant",
|
|
32
|
+
distinct_id="u_1", conversation_id="thread_1",
|
|
33
|
+
) as run:
|
|
34
|
+
run.set_input([{"role": "user", "content": "why did revenue drop"}])
|
|
35
|
+
|
|
36
|
+
with span_ctx("planner", kind="agent") as s:
|
|
37
|
+
s.set_output({"subtasks": ["pricing", "churn"]})
|
|
38
|
+
|
|
39
|
+
for topic in ("pricing", "churn"):
|
|
40
|
+
with span_ctx(f"researcher:{topic}", kind="agent") as s:
|
|
41
|
+
s.set_attribute("topic", topic)
|
|
42
|
+
# The sub-agent's own tool call nests under the sub-agent, not the run.
|
|
43
|
+
with span_ctx("web_search", kind="tool") as t:
|
|
44
|
+
t.set_tool("web_search")
|
|
45
|
+
t.set_output({"hits": 3})
|
|
46
|
+
s.set_output({"topic": topic})
|
|
47
|
+
|
|
48
|
+
run.set_output({"answer": "pricing change in March"})
|
|
49
|
+
|
|
50
|
+
# Exactly ONE run — not one per sub-agent.
|
|
51
|
+
assert len(http.run_ingest) == 1
|
|
52
|
+
payload = http.run_ingest[0]
|
|
53
|
+
assert payload["run"]["agent_name"] == "research_assistant"
|
|
54
|
+
assert payload["run"]["conversation_id"] == "thread_1"
|
|
55
|
+
|
|
56
|
+
tree = _tree(payload)
|
|
57
|
+
by_name = {v[0]: (sid, v[1], v[2]) for sid, v in tree.items()}
|
|
58
|
+
|
|
59
|
+
# Three agent-kind sub-agent spans, all siblings directly under the run root.
|
|
60
|
+
parents = set()
|
|
61
|
+
for name in ("planner", "researcher:pricing", "researcher:churn"):
|
|
62
|
+
sid, kind, parent = by_name[name]
|
|
63
|
+
assert kind == "agent", f"{name} should be an agent span, got {kind}"
|
|
64
|
+
parents.add(parent)
|
|
65
|
+
assert len(parents) == 1, "sub-agents should be siblings under one run root"
|
|
66
|
+
|
|
67
|
+
# Each tool span nests under its own researcher, preserving the delegation tree.
|
|
68
|
+
tool_parents = {v[2] for sid, v in tree.items() if v[1] == "tool"}
|
|
69
|
+
researcher_ids = {by_name[n][0] for n in ("researcher:pricing", "researcher:churn")}
|
|
70
|
+
assert tool_parents == researcher_ids
|
|
71
|
+
|
|
72
|
+
# The run rolls up the whole request, which is the point of keeping it one run.
|
|
73
|
+
assert payload["run"]["span_count"] == 5
|
|
74
|
+
assert payload["run"]["tool_count"] == 2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_independently_triggered_sub_agent_links_via_parent_run_id(processor, http):
|
|
78
|
+
"""The other shape: separate runs, linked — for independently-triggered work."""
|
|
79
|
+
with wrap_agent_ctx(
|
|
80
|
+
processor=processor, team_site_id="site", agent_name="intake", distinct_id="u_1",
|
|
81
|
+
) as parent:
|
|
82
|
+
parent.set_output({"job": "enrich"})
|
|
83
|
+
|
|
84
|
+
with wrap_agent_ctx(
|
|
85
|
+
processor=processor, team_site_id="site", agent_name="enrichment",
|
|
86
|
+
distinct_id="u_1", parent_run_id=parent.run_id,
|
|
87
|
+
) as child:
|
|
88
|
+
child.set_output({"done": True})
|
|
89
|
+
|
|
90
|
+
assert len(http.run_ingest) == 2
|
|
91
|
+
assert http.run_ingest[1]["run"]["parent_run_id"] == parent.run_id
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_module_level_wrappers_forward_user_and_parent_run_id():
|
|
95
|
+
"""``trodo.wrap_agent(..., user=...)`` is what the skill tells people to write.
|
|
96
|
+
|
|
97
|
+
The module-level wrappers used to drop ``user`` on the floor (and ``agent``
|
|
98
|
+
also dropped ``parent_run_id``), so the documented call raised TypeError.
|
|
99
|
+
"""
|
|
100
|
+
for fn, expected in (
|
|
101
|
+
(trodo.wrap_agent, {"user", "parent_run_id", "conversation_id", "metadata"}),
|
|
102
|
+
(trodo.agent, {"user", "parent_run_id", "conversation_id", "metadata"}),
|
|
103
|
+
(trodo.start_run, {"user", "parent_run_id", "conversation_id", "metadata", "input"}),
|
|
104
|
+
):
|
|
105
|
+
params = set(inspect.signature(fn).parameters)
|
|
106
|
+
assert expected <= params, f"{fn.__name__} is missing {expected - params}"
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""User traits — ``upsert_user``, the ``user`` bag on runs, and bounded caches.
|
|
2
|
+
|
|
3
|
+
The stateless-backend story: a request handler knows who the user is and a few
|
|
4
|
+
things about them for one request, and holds nothing between requests. These
|
|
5
|
+
cover the ways that gets expressed, plus the memory ceiling that makes holding
|
|
6
|
+
per-user state safe in a long-lived process.
|
|
7
|
+
|
|
8
|
+
Mirrors ``sdks/trodo-node-sdk/tests/userTraits.test.ts``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from trodo.managers.user_manager import UserManager
|
|
16
|
+
from trodo.otel.processor import TrodoSpanProcessor
|
|
17
|
+
from trodo.otel.wrap_agent import start_run
|
|
18
|
+
from trodo.otel.wrap_agent import wrap_agent as wrap_agent_ctx
|
|
19
|
+
from trodo.util.lru import LruDict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UpsertRecorder:
|
|
23
|
+
"""Stands in for HttpClient, recording exact wire payloads."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.calls = []
|
|
27
|
+
|
|
28
|
+
def post_user_upsert(self, payload):
|
|
29
|
+
self.calls.append(payload)
|
|
30
|
+
return {"success": True, "distinctId": payload["distinctId"], "created": False}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RunRecorder:
|
|
34
|
+
"""Stands in for the processor's HTTP client."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.ingested = []
|
|
38
|
+
self.started = []
|
|
39
|
+
|
|
40
|
+
def post_run_ingest(self, payload):
|
|
41
|
+
self.ingested.append(payload)
|
|
42
|
+
return {"success": True}
|
|
43
|
+
|
|
44
|
+
def post_run_start(self, payload):
|
|
45
|
+
self.started.append(payload)
|
|
46
|
+
return {"success": True}
|
|
47
|
+
|
|
48
|
+
def post_run_end(self, run_id, payload):
|
|
49
|
+
return {"success": True}
|
|
50
|
+
|
|
51
|
+
def post_spans_append(self, run_id, spans):
|
|
52
|
+
return {"success": True}
|
|
53
|
+
|
|
54
|
+
def post_runless_spans(self, spans):
|
|
55
|
+
return {"success": True}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# upsert_user
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_upsert_sends_every_bag():
|
|
64
|
+
http = UpsertRecorder()
|
|
65
|
+
users = UserManager(http)
|
|
66
|
+
|
|
67
|
+
users.upsert(
|
|
68
|
+
"user-42",
|
|
69
|
+
properties={"plan": "pro"},
|
|
70
|
+
set_once={"signup_date": "2026-01-04"},
|
|
71
|
+
fixed_properties={"last_location_country": "India"},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert http.calls[0] == {
|
|
75
|
+
"distinctId": "user-42",
|
|
76
|
+
"properties": {"plan": "pro"},
|
|
77
|
+
"setOnce": {"signup_date": "2026-01-04"},
|
|
78
|
+
"fixedProperties": {"last_location_country": "India"},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_upsert_omits_bags_not_supplied():
|
|
83
|
+
http = UpsertRecorder()
|
|
84
|
+
UserManager(http).upsert("user-42", properties={"plan": "pro"})
|
|
85
|
+
|
|
86
|
+
# The server tells "no bag" from "empty bag" by presence.
|
|
87
|
+
assert sorted(http.calls[0].keys()) == ["distinctId", "properties"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_upsert_with_no_traits_still_ensures_the_user():
|
|
91
|
+
http = UpsertRecorder()
|
|
92
|
+
UserManager(http).upsert("user-new")
|
|
93
|
+
|
|
94
|
+
assert http.calls == [{"distinctId": "user-new"}]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_upsert_uses_camel_case_on_the_wire():
|
|
98
|
+
"""Python callers write snake_case; the wire contract is camelCase."""
|
|
99
|
+
http = UpsertRecorder()
|
|
100
|
+
UserManager(http).upsert("user-42", set_once={"a": 1}, fixed_properties={"b": 2})
|
|
101
|
+
|
|
102
|
+
assert "setOnce" in http.calls[0]
|
|
103
|
+
assert "fixedProperties" in http.calls[0]
|
|
104
|
+
assert "set_once" not in http.calls[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# The `user` bag on runs
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_user_bag_rides_on_the_run_payload():
|
|
113
|
+
http = RunRecorder()
|
|
114
|
+
processor = TrodoSpanProcessor(http_client=http)
|
|
115
|
+
|
|
116
|
+
with wrap_agent_ctx(
|
|
117
|
+
processor=processor,
|
|
118
|
+
team_site_id="site_abc",
|
|
119
|
+
agent_name="support",
|
|
120
|
+
distinct_id="user-42",
|
|
121
|
+
user={"properties": {"plan": "pro"}},
|
|
122
|
+
) as run:
|
|
123
|
+
run.set_output("done")
|
|
124
|
+
|
|
125
|
+
processor.force_flush()
|
|
126
|
+
payload = http.ingested[0]
|
|
127
|
+
assert payload["run"]["user"] == {"properties": {"plan": "pro"}}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_user_bag_accepts_snake_case_from_python_callers():
|
|
131
|
+
http = RunRecorder()
|
|
132
|
+
processor = TrodoSpanProcessor(http_client=http)
|
|
133
|
+
|
|
134
|
+
with wrap_agent_ctx(
|
|
135
|
+
processor=processor,
|
|
136
|
+
team_site_id="site_abc",
|
|
137
|
+
agent_name="support",
|
|
138
|
+
distinct_id="user-42",
|
|
139
|
+
user={"fixed_properties": {"last_location_country": "India"}},
|
|
140
|
+
) as run:
|
|
141
|
+
run.set_output("done")
|
|
142
|
+
|
|
143
|
+
processor.force_flush()
|
|
144
|
+
# Normalised to the camelCase wire key.
|
|
145
|
+
assert http.ingested[0]["run"]["user"] == {
|
|
146
|
+
"fixedProperties": {"last_location_country": "India"}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_user_bag_absent_when_not_passed():
|
|
151
|
+
http = RunRecorder()
|
|
152
|
+
processor = TrodoSpanProcessor(http_client=http)
|
|
153
|
+
|
|
154
|
+
with wrap_agent_ctx(
|
|
155
|
+
processor=processor,
|
|
156
|
+
team_site_id="site_abc",
|
|
157
|
+
agent_name="support",
|
|
158
|
+
distinct_id="user-42",
|
|
159
|
+
) as run:
|
|
160
|
+
run.set_output("done")
|
|
161
|
+
|
|
162
|
+
processor.force_flush()
|
|
163
|
+
assert http.ingested[0]["run"].get("user") is None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_empty_bag_is_dropped_rather_than_sent():
|
|
167
|
+
http = RunRecorder()
|
|
168
|
+
processor = TrodoSpanProcessor(http_client=http)
|
|
169
|
+
|
|
170
|
+
with wrap_agent_ctx(
|
|
171
|
+
processor=processor,
|
|
172
|
+
team_site_id="site_abc",
|
|
173
|
+
agent_name="support",
|
|
174
|
+
distinct_id="user-42",
|
|
175
|
+
user={"properties": {}},
|
|
176
|
+
) as run:
|
|
177
|
+
run.set_output("done")
|
|
178
|
+
|
|
179
|
+
processor.force_flush()
|
|
180
|
+
assert http.ingested[0]["run"].get("user") is None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_user_bag_travels_when_the_run_fails():
|
|
184
|
+
"""A run that threw is exactly when you still want to know who the user was."""
|
|
185
|
+
http = RunRecorder()
|
|
186
|
+
processor = TrodoSpanProcessor(http_client=http)
|
|
187
|
+
|
|
188
|
+
with pytest.raises(ValueError):
|
|
189
|
+
with wrap_agent_ctx(
|
|
190
|
+
processor=processor,
|
|
191
|
+
team_site_id="site_abc",
|
|
192
|
+
agent_name="support",
|
|
193
|
+
distinct_id="user-42",
|
|
194
|
+
user={"properties": {"plan": "pro"}},
|
|
195
|
+
):
|
|
196
|
+
raise ValueError("boom")
|
|
197
|
+
|
|
198
|
+
processor.force_flush()
|
|
199
|
+
run = http.ingested[0]["run"]
|
|
200
|
+
assert run["status"] == "error"
|
|
201
|
+
assert run["user"] == {"properties": {"plan": "pro"}}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_start_run_carries_the_user_bag():
|
|
205
|
+
http = RunRecorder()
|
|
206
|
+
processor = TrodoSpanProcessor(http_client=http)
|
|
207
|
+
|
|
208
|
+
start_run(
|
|
209
|
+
processor=processor,
|
|
210
|
+
agent_name="support",
|
|
211
|
+
distinct_id="user-42",
|
|
212
|
+
user={"properties": {"plan": "pro"}},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
payload = http.started[0]
|
|
216
|
+
run = payload.get("run", payload)
|
|
217
|
+
assert run["user"] == {"properties": {"plan": "pro"}}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# LruDict — the memory ceiling behind for_user()
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_lru_holds_entries_below_the_cap():
|
|
226
|
+
d = LruDict(3)
|
|
227
|
+
d.set("a", 1)
|
|
228
|
+
d.set("b", 2)
|
|
229
|
+
d.set("c", 3)
|
|
230
|
+
assert len(d) == 3
|
|
231
|
+
assert d.get("a") == 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_lru_evicts_least_recently_used():
|
|
235
|
+
d = LruDict(3)
|
|
236
|
+
for k, v in [("a", 1), ("b", 2), ("c", 3)]:
|
|
237
|
+
d.set(k, v)
|
|
238
|
+
d.set("d", 4)
|
|
239
|
+
|
|
240
|
+
assert len(d) == 3
|
|
241
|
+
assert d.get("a") is None
|
|
242
|
+
assert d.get("d") == 4
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_lru_read_counts_as_use():
|
|
246
|
+
d = LruDict(3)
|
|
247
|
+
for k, v in [("a", 1), ("b", 2), ("c", 3)]:
|
|
248
|
+
d.set(k, v)
|
|
249
|
+
d.get("a") # 'a' is newest now, 'b' is oldest
|
|
250
|
+
d.set("d", 4)
|
|
251
|
+
|
|
252
|
+
assert d.get("a") == 1
|
|
253
|
+
assert d.get("b") is None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_lru_reset_refreshes_rather_than_duplicating():
|
|
257
|
+
d = LruDict(2)
|
|
258
|
+
d.set("a", 1)
|
|
259
|
+
d.set("b", 2)
|
|
260
|
+
d.set("a", 99)
|
|
261
|
+
d.set("c", 3)
|
|
262
|
+
|
|
263
|
+
assert d.get("a") == 99
|
|
264
|
+
assert d.get("b") is None
|
|
265
|
+
assert len(d) == 2
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_lru_footprint_stays_bounded_across_many_users():
|
|
269
|
+
d = LruDict(100)
|
|
270
|
+
for i in range(20_000):
|
|
271
|
+
d.set(f"user-{i}", i)
|
|
272
|
+
assert len(d) == 100
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_lru_falls_back_to_default_on_nonsense_cap():
|
|
276
|
+
d = LruDict(0)
|
|
277
|
+
d.set("a", 1)
|
|
278
|
+
assert d.get("a") == 1
|
|
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
|
|
|
41
41
|
|
|
42
42
|
from __future__ import annotations
|
|
43
43
|
|
|
44
|
-
__version__ = "2.
|
|
44
|
+
__version__ = "2.18.0"
|
|
45
45
|
|
|
46
46
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
47
47
|
|
|
@@ -80,6 +80,7 @@ __all__ = [
|
|
|
80
80
|
"for_user",
|
|
81
81
|
"track",
|
|
82
82
|
"identify",
|
|
83
|
+
"upsert_user",
|
|
83
84
|
"people_set",
|
|
84
85
|
"people_set_once",
|
|
85
86
|
"wallet_address",
|
|
@@ -256,6 +257,44 @@ def identify(identify_id: str, session_id: Optional[str] = None) -> UserContext:
|
|
|
256
257
|
return _get_client().identify(identify_id, session_id)
|
|
257
258
|
|
|
258
259
|
|
|
260
|
+
def upsert_user(
|
|
261
|
+
distinct_id: str,
|
|
262
|
+
*,
|
|
263
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
264
|
+
set_once: Optional[Dict[str, Any]] = None,
|
|
265
|
+
fixed_properties: Optional[Dict[str, Any]] = None,
|
|
266
|
+
):
|
|
267
|
+
"""Create-or-update a user and their traits in one idempotent call.
|
|
268
|
+
|
|
269
|
+
The stateless-backend primitive: a request handler knows who the user is and
|
|
270
|
+
a few things about them for the duration of one request, and this is how you
|
|
271
|
+
say so. Safe to call on every request.
|
|
272
|
+
|
|
273
|
+
trodo.upsert_user(
|
|
274
|
+
"user-42",
|
|
275
|
+
properties={"plan": "pro"},
|
|
276
|
+
set_once={"signup_date": "2026-01-04"},
|
|
277
|
+
fixed_properties={"last_location_country": "India"},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
Every bag is optional — with none of them it still ensures the user exists,
|
|
281
|
+
which is useful at signup, before they have produced any agent runs.
|
|
282
|
+
|
|
283
|
+
``fixed_properties`` writes the real profile columns the dashboard filters
|
|
284
|
+
on, limited to what a backend can honestly know (country, city). Device,
|
|
285
|
+
browser, OS, referrer and UTM are browser observations and come back in
|
|
286
|
+
``skipped`` instead of being stored.
|
|
287
|
+
|
|
288
|
+
Mirrors ``trodo.users.upsert(...)`` in trodo-node.
|
|
289
|
+
"""
|
|
290
|
+
return _get_client().upsert_user(
|
|
291
|
+
distinct_id,
|
|
292
|
+
properties=properties,
|
|
293
|
+
set_once=set_once,
|
|
294
|
+
fixed_properties=fixed_properties,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
259
298
|
def people_set(distinct_id: str, properties: Dict[str, Any]):
|
|
260
299
|
"""Set profile properties for a user — module-level convenience mirroring
|
|
261
300
|
``trodo.people.set(distinctId, props)`` in trodo-node. Equivalent to
|
|
@@ -485,14 +524,21 @@ def wrap_agent(
|
|
|
485
524
|
conversation_id: Optional[str] = None,
|
|
486
525
|
parent_run_id: Optional[str] = None,
|
|
487
526
|
metadata: Optional[Dict[str, Any]] = None,
|
|
527
|
+
user: Optional[Dict[str, Any]] = None,
|
|
488
528
|
) -> _wrap_agent_ctx:
|
|
489
|
-
"""Wrap a block as an agent run — returns a context manager yielding RunHandle.
|
|
529
|
+
"""Wrap a block as an agent run — returns a context manager yielding RunHandle.
|
|
530
|
+
|
|
531
|
+
``user`` carries profile traits alongside the run — ``{"properties": ...,
|
|
532
|
+
"set_once": ..., "fixed_properties": ...}`` — so a request handler can
|
|
533
|
+
attribute the run and enrich the profile in one round trip.
|
|
534
|
+
"""
|
|
490
535
|
return _get_client().wrap_agent(
|
|
491
536
|
agent_name,
|
|
492
537
|
distinct_id=distinct_id,
|
|
493
538
|
conversation_id=conversation_id,
|
|
494
539
|
parent_run_id=parent_run_id,
|
|
495
540
|
metadata=metadata,
|
|
541
|
+
user=user,
|
|
496
542
|
)
|
|
497
543
|
|
|
498
544
|
|
|
@@ -501,14 +547,18 @@ def agent(
|
|
|
501
547
|
*,
|
|
502
548
|
distinct_id: Optional[str] = None,
|
|
503
549
|
conversation_id: Optional[str] = None,
|
|
550
|
+
parent_run_id: Optional[str] = None,
|
|
504
551
|
metadata: Optional[Dict[str, Any]] = None,
|
|
552
|
+
user: Optional[Dict[str, Any]] = None,
|
|
505
553
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
506
554
|
"""Decorator form of wrap_agent."""
|
|
507
555
|
return _get_client().agent(
|
|
508
556
|
agent_name,
|
|
509
557
|
distinct_id=distinct_id,
|
|
510
558
|
conversation_id=conversation_id,
|
|
559
|
+
parent_run_id=parent_run_id,
|
|
511
560
|
metadata=metadata,
|
|
561
|
+
user=user,
|
|
512
562
|
)
|
|
513
563
|
|
|
514
564
|
|
|
@@ -552,6 +602,7 @@ def start_run(
|
|
|
552
602
|
parent_run_id: Optional[str] = None,
|
|
553
603
|
metadata: Optional[Dict[str, Any]] = None,
|
|
554
604
|
input: Any = None,
|
|
605
|
+
user: Optional[Dict[str, Any]] = None,
|
|
555
606
|
) -> str:
|
|
556
607
|
"""Open a Run record without a context manager.
|
|
557
608
|
|
|
@@ -568,6 +619,7 @@ def start_run(
|
|
|
568
619
|
parent_run_id=parent_run_id,
|
|
569
620
|
metadata=metadata,
|
|
570
621
|
input=input,
|
|
622
|
+
user=user,
|
|
571
623
|
)
|
|
572
624
|
|
|
573
625
|
|
|
@@ -4,6 +4,8 @@ EVENTS_BULK = "/api/events/bulk"
|
|
|
4
4
|
IDENTIFY = "/api/sdk/identify"
|
|
5
5
|
WALLET_ADDRESS = "/api/sdk/wallet-address"
|
|
6
6
|
RESET = "/api/sdk/reset"
|
|
7
|
+
# Identity + traits in one idempotent call.
|
|
8
|
+
USERS_UPSERT = "/api/sdk/users/upsert"
|
|
7
9
|
PEOPLE_SET = "/api/sdk/people/set"
|
|
8
10
|
PEOPLE_SET_ONCE = "/api/sdk/people/set_once"
|
|
9
11
|
PEOPLE_UNSET = "/api/sdk/people/unset"
|