trodo-python 2.16.0__tar.gz → 2.18.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 (63) hide show
  1. {trodo_python-2.16.0 → trodo_python-2.18.0}/PKG-INFO +57 -4
  2. {trodo_python-2.16.0 → trodo_python-2.18.0}/README.md +56 -3
  3. {trodo_python-2.16.0 → trodo_python-2.18.0}/pyproject.toml +1 -1
  4. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_experiments.py +56 -0
  5. trodo_python-2.18.0/tests/test_user_traits.py +278 -0
  6. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/__init__.py +94 -2
  7. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/api/endpoints.py +7 -1
  8. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/api/http_client.py +34 -0
  9. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/client.py +58 -8
  10. trodo_python-2.18.0/trodo/managers/experiment_manager.py +286 -0
  11. trodo_python-2.18.0/trodo/managers/user_manager.py +70 -0
  12. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/processor.py +4 -0
  13. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/wrap_agent.py +31 -0
  14. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/session/session_manager.py +11 -5
  15. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/user_context.py +8 -5
  16. trodo_python-2.18.0/trodo/util/__init__.py +1 -0
  17. trodo_python-2.18.0/trodo/util/lru.py +71 -0
  18. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo_python.egg-info/PKG-INFO +57 -4
  19. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo_python.egg-info/SOURCES.txt +4 -0
  20. trodo_python-2.16.0/trodo/managers/experiment_manager.py +0 -105
  21. {trodo_python-2.16.0 → trodo_python-2.18.0}/setup.cfg +0 -0
  22. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_anon_distinct_id.py +0 -0
  23. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_auto_instrument_fixes.py +0 -0
  24. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_cross_process_session.py +0 -0
  25. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_datasets.py +0 -0
  26. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_end_run.py +0 -0
  27. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_error_enrichment.py +0 -0
  28. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_llm_usage_cost.py +0 -0
  29. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_processor_methods.py +0 -0
  30. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_prompt_trace.py +0 -0
  31. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_prompts.py +0 -0
  32. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_register_otel.py +0 -0
  33. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_start_run.py +0 -0
  34. {trodo_python-2.16.0 → trodo_python-2.18.0}/tests/test_wrap_agent_unchanged.py +0 -0
  35. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/api/__init__.py +0 -0
  36. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/api/async_client.py +0 -0
  37. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/auto/__init__.py +0 -0
  38. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/auto/auto_event_manager.py +0 -0
  39. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/managers/__init__.py +0 -0
  40. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/managers/dataset_manager.py +0 -0
  41. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/managers/group_manager.py +0 -0
  42. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/managers/people_manager.py +0 -0
  43. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/managers/prompt_manager.py +0 -0
  44. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/__init__.py +0 -0
  45. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/auto_instrument.py +0 -0
  46. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/context.py +0 -0
  47. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/helpers.py +0 -0
  48. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/prompt_trace.py +0 -0
  49. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/register.py +0 -0
  50. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/otel/transport.py +0 -0
  51. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/prompts/__init__.py +0 -0
  52. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/prompts/compile.py +0 -0
  53. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/prompts/template.py +0 -0
  54. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/prompts/types.py +0 -0
  55. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/queue/__init__.py +0 -0
  56. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/queue/batch_flusher.py +0 -0
  57. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/queue/event_queue.py +0 -0
  58. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/session/__init__.py +0 -0
  59. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/session/server_session.py +0 -0
  60. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo/types.py +0 -0
  61. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  62. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo_python.egg-info/requires.txt +0 -0
  63. {trodo_python-2.16.0 → trodo_python-2.18.0}/trodo_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.16.0
3
+ Version: 2.18.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
@@ -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 id_user@example.com — merges with browser events
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') # → id_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') → id_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 id_user@example.com — merges with browser events
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') # → id_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') → id_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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.16.0"
7
+ version = "2.18.0"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -118,3 +118,59 @@ def test_ingest_requires_item_position_and_output():
118
118
  ExperimentManager(FakeHttp()).ingest("ds", [{"output": "o"}])
119
119
  with pytest.raises(ValueError, match="requires an 'output'"):
120
120
  ExperimentManager(FakeHttp()).ingest("ds", [{"item_position": 0}])
121
+
122
+
123
+ class FakeManagedHttp(FakeHttp):
124
+ def __init__(self):
125
+ super().__init__()
126
+ self.create_body = None
127
+ self.run_body = None
128
+ self.compare_args = None
129
+
130
+ def create_experiment(self, body):
131
+ self.create_body = body
132
+ return {"experiment": {"id": "exp_p", "status": "pending"}}
133
+
134
+ def run_experiment(self, body):
135
+ self.run_body = body
136
+ return {"experiment": {"id": "exp_done", "status": "completed"}}
137
+
138
+ def get_experiment(self, experiment_id):
139
+ return {"experiment": {"id": experiment_id, "status": "completed"}}
140
+
141
+ def compare_experiments(self, a_id, b_id, strict=False):
142
+ self.compare_args = (a_id, b_id, strict)
143
+ return {"a": {"id": a_id}, "b": {"id": b_id}, "join": "dataset_item_id"}
144
+
145
+
146
+ def test_create_posts_managed_body():
147
+ http = FakeManagedHttp()
148
+ exp = ExperimentManager(http).create(
149
+ dataset="golden",
150
+ models=[{"credential_id": "1", "provider": "openai", "model": "gpt-4o"}],
151
+ prompt_ref={"name": "support", "version": "abc"},
152
+ scorer_ids=[1, 2],
153
+ )
154
+ assert http.create_body["dataset_id"] == "golden"
155
+ assert http.create_body["scorer_ids"] == [1, 2]
156
+ assert exp["status"] == "pending"
157
+
158
+
159
+ def test_run_posts_and_returns_completed():
160
+ http = FakeManagedHttp()
161
+ exp = ExperimentManager(http).run(
162
+ dataset="golden",
163
+ models=[{"credential_id": "1", "provider": "openai", "model": "gpt-4o"}],
164
+ name="ci",
165
+ )
166
+ assert http.run_body["name"] == "ci"
167
+ assert exp["status"] == "completed"
168
+
169
+
170
+ def test_get_and_compare():
171
+ http = FakeManagedHttp()
172
+ mgr = ExperimentManager(http)
173
+ assert mgr.get("exp_1")["id"] == "exp_1"
174
+ res = mgr.compare("a", "b", strict=True)
175
+ assert http.compare_args == ("a", "b", True)
176
+ assert res["join"] == "dataset_item_id"
@@ -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.16.0"
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",
@@ -119,9 +120,14 @@ __all__ = [
119
120
  "TemplateError",
120
121
  "ManagedPrompt",
121
122
  "PromptSummary",
122
- # Datasets & experiments ingest
123
+ # Datasets & experiments
123
124
  "append_dataset",
124
125
  "ingest_experiment",
126
+ "create_experiment",
127
+ "run_experiment",
128
+ "get_experiment",
129
+ "wait_experiment",
130
+ "compare_experiments",
125
131
  ]
126
132
 
127
133
  # ============================================================================
@@ -251,6 +257,44 @@ def identify(identify_id: str, session_id: Optional[str] = None) -> UserContext:
251
257
  return _get_client().identify(identify_id, session_id)
252
258
 
253
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
+
254
298
  def people_set(distinct_id: str, properties: Dict[str, Any]):
255
299
  """Set profile properties for a user — module-level convenience mirroring
256
300
  ``trodo.people.set(distinctId, props)`` in trodo-node. Equivalent to
@@ -405,6 +449,54 @@ def ingest_experiment(
405
449
  )
406
450
 
407
451
 
452
+ def create_experiment(
453
+ *,
454
+ dataset: Optional[str] = None,
455
+ dataset_id: Optional[str] = None,
456
+ models: List[Dict[str, Any]],
457
+ **kwargs: Any,
458
+ ) -> Dict[str, Any]:
459
+ """Create an experiment (immutable refs, status pending)."""
460
+ return _get_client().experiments.create(
461
+ dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
462
+ )
463
+
464
+
465
+ def run_experiment(
466
+ *,
467
+ dataset: Optional[str] = None,
468
+ dataset_id: Optional[str] = None,
469
+ models: List[Dict[str, Any]],
470
+ **kwargs: Any,
471
+ ) -> Dict[str, Any]:
472
+ """Create and run an experiment synchronously."""
473
+ return _get_client().experiments.run(
474
+ dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
475
+ )
476
+
477
+
478
+ def get_experiment(experiment_id: str) -> Dict[str, Any]:
479
+ """Fetch an experiment by id."""
480
+ return _get_client().experiments.get(experiment_id)
481
+
482
+
483
+ def wait_experiment(
484
+ experiment_id: str,
485
+ *,
486
+ interval_ms: int = 2500,
487
+ timeout_ms: int = 600_000,
488
+ ) -> Dict[str, Any]:
489
+ """Poll until the experiment completes or fails."""
490
+ return _get_client().experiments.wait(
491
+ experiment_id, interval_ms=interval_ms, timeout_ms=timeout_ms
492
+ )
493
+
494
+
495
+ def compare_experiments(a_id: str, b_id: str, *, strict: bool = False) -> Dict[str, Any]:
496
+ """Compare two experiments side by side."""
497
+ return _get_client().experiments.compare(a_id, b_id, strict=strict)
498
+
499
+
408
500
  def enable_auto_events() -> None:
409
501
  _get_client().enable_auto_events()
410
502
 
@@ -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"
@@ -23,6 +25,10 @@ RUNS_INGEST = "/api/sdk/runs/ingest"
23
25
  RUNS_START = "/api/sdk/runs/start"
24
26
  RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
25
27
  OTLP_TRACES = "/api/sdk/otel/v1/traces"
26
- # Datasets & experiments ingest
28
+ # Datasets & experiments
27
29
  DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
30
+ EXPERIMENTS = "/api/sdk/experiments"
28
31
  EXPERIMENTS_INGEST = "/api/sdk/experiments/ingest"
32
+ EXPERIMENTS_CREATE = "/api/sdk/experiments/create"
33
+ EXPERIMENTS_RUN = "/api/sdk/experiments/run"
34
+ EXPERIMENTS_COMPARE = "/api/sdk/experiments/compare"