trodo-python 2.17.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.
- {trodo_python-2.17.0 → trodo_python-2.18.0}/PKG-INFO +57 -4
- {trodo_python-2.17.0 → trodo_python-2.18.0}/README.md +56 -3
- {trodo_python-2.17.0 → trodo_python-2.18.0}/pyproject.toml +1 -1
- trodo_python-2.18.0/tests/test_user_traits.py +278 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/__init__.py +40 -1
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/api/endpoints.py +2 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/api/http_client.py +12 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/client.py +57 -7
- trodo_python-2.18.0/trodo/managers/user_manager.py +70 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/processor.py +4 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/wrap_agent.py +31 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/session/session_manager.py +11 -5
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/user_context.py +8 -5
- trodo_python-2.18.0/trodo/util/__init__.py +1 -0
- trodo_python-2.18.0/trodo/util/lru.py +71 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo_python.egg-info/PKG-INFO +57 -4
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo_python.egg-info/SOURCES.txt +4 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/setup.cfg +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_datasets.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_experiments.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_prompt_trace.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_prompts.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/managers/dataset_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/managers/experiment_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/managers/prompt_manager.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/prompts/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/prompts/compile.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/prompts/template.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/prompts/types.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo/types.py +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.17.0 → trodo_python-2.18.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.17.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.
|
|
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
|
|
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,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
|
|
@@ -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"
|
|
@@ -9,6 +9,7 @@ from typing import Any, Callable, Dict, Optional
|
|
|
9
9
|
import requests
|
|
10
10
|
|
|
11
11
|
from ..types import ApiResult, EventPayload
|
|
12
|
+
from . import endpoints
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class HttpClient:
|
|
@@ -183,6 +184,17 @@ class HttpClient:
|
|
|
183
184
|
def post_reset(self, payload: Dict[str, Any]) -> ApiResult:
|
|
184
185
|
return self._request("/api/sdk/reset", payload)
|
|
185
186
|
|
|
187
|
+
def post_user_upsert(self, payload: Dict[str, Any]) -> ApiResult:
|
|
188
|
+
"""Create-or-update a user and their traits.
|
|
189
|
+
|
|
190
|
+
Degrades gracefully against a backend that predates the endpoint:
|
|
191
|
+
``_request`` never raises, so a 404 surfaces through ``on_error`` and
|
|
192
|
+
returns an empty result rather than breaking the caller's request
|
|
193
|
+
handler. Users on an older backend lose the trait write, not their
|
|
194
|
+
application.
|
|
195
|
+
"""
|
|
196
|
+
return self._request(endpoints.USERS_UPSERT, payload)
|
|
197
|
+
|
|
186
198
|
def post_people(self, path: str, payload: Dict[str, Any]) -> ApiResult:
|
|
187
199
|
return self._request(path, payload)
|
|
188
200
|
|
|
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
|
|
|
7
7
|
|
|
8
8
|
from .api.http_client import HttpClient
|
|
9
9
|
from .session.session_manager import SessionManager
|
|
10
|
+
from .util.lru import LruDict
|
|
10
11
|
from .managers.group_manager import GroupProfile
|
|
11
12
|
from .queue.event_queue import EventQueue
|
|
12
13
|
from .queue.batch_flusher import BatchFlusher
|
|
@@ -59,6 +60,7 @@ class TrodoClient:
|
|
|
59
60
|
auto_instrument: bool = True,
|
|
60
61
|
disable_instrumentations: Optional[List[str]] = None,
|
|
61
62
|
otel_mode: str = "trodo",
|
|
63
|
+
max_cached_users: int = 10_000,
|
|
62
64
|
) -> None:
|
|
63
65
|
if not site_id:
|
|
64
66
|
raise ValueError("trodo-python: site_id is required")
|
|
@@ -75,10 +77,11 @@ class TrodoClient:
|
|
|
75
77
|
debug=debug,
|
|
76
78
|
)
|
|
77
79
|
|
|
78
|
-
self._session_manager = SessionManager()
|
|
80
|
+
self._session_manager = SessionManager(max_cached_users)
|
|
79
81
|
self._prompts = None # lazily-built PromptManager
|
|
80
82
|
self._datasets = None # lazily-built DatasetManager
|
|
81
83
|
self._experiments = None # lazily-built ExperimentManager
|
|
84
|
+
self._users = None # lazily-built UserManager
|
|
82
85
|
|
|
83
86
|
if batch_enabled:
|
|
84
87
|
self._event_queue: Optional[EventQueue] = EventQueue(batch_size)
|
|
@@ -97,7 +100,10 @@ class TrodoClient:
|
|
|
97
100
|
if auto_events:
|
|
98
101
|
self._auto_event_manager.enable()
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
# LRU-bounded (max_cached_users, default 10k). ``for_user`` caches a
|
|
104
|
+
# context per distinct id; unbounded that is a slow leak in a long-lived
|
|
105
|
+
# API server. An evicted user is simply rebuilt on next use.
|
|
106
|
+
self._user_context_cache: LruDict[str, UserContext] = LruDict(max_cached_users)
|
|
101
107
|
|
|
102
108
|
self._span_processor = TrodoSpanProcessor(http_client=self._http)
|
|
103
109
|
if auto_instrument:
|
|
@@ -143,6 +149,15 @@ class TrodoClient:
|
|
|
143
149
|
self._experiments = ExperimentManager(self._http)
|
|
144
150
|
return self._experiments
|
|
145
151
|
|
|
152
|
+
@property
|
|
153
|
+
def users(self):
|
|
154
|
+
"""Create-or-update a user and their traits — the stateless-backend
|
|
155
|
+
primitive. See :class:`~trodo.managers.user_manager.UserManager`."""
|
|
156
|
+
if self._users is None:
|
|
157
|
+
from .managers.user_manager import UserManager
|
|
158
|
+
self._users = UserManager(self._http)
|
|
159
|
+
return self._users
|
|
160
|
+
|
|
146
161
|
# --------------------------------------------------------------------------
|
|
147
162
|
# Primary pattern: for_user()
|
|
148
163
|
# --------------------------------------------------------------------------
|
|
@@ -152,8 +167,9 @@ class TrodoClient:
|
|
|
152
167
|
distinct_id: str,
|
|
153
168
|
session_id: Optional[str] = None,
|
|
154
169
|
) -> UserContext:
|
|
155
|
-
|
|
156
|
-
|
|
170
|
+
cached = self._user_context_cache.get(distinct_id)
|
|
171
|
+
if cached is not None:
|
|
172
|
+
return cached
|
|
157
173
|
|
|
158
174
|
ctx = UserContext(
|
|
159
175
|
distinct_id=distinct_id,
|
|
@@ -165,7 +181,7 @@ class TrodoClient:
|
|
|
165
181
|
auto_event_manager=self._auto_event_manager,
|
|
166
182
|
session_id=session_id,
|
|
167
183
|
)
|
|
168
|
-
self._user_context_cache
|
|
184
|
+
self._user_context_cache.set(distinct_id, ctx)
|
|
169
185
|
return ctx
|
|
170
186
|
|
|
171
187
|
# --------------------------------------------------------------------------
|
|
@@ -182,8 +198,9 @@ class TrodoClient:
|
|
|
182
198
|
self.for_user(distinct_id).track(event_name, properties, category)
|
|
183
199
|
|
|
184
200
|
def identify(self, identify_id: str, session_id: Optional[str] = None) -> "UserContext":
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
cached = self._user_context_cache.get(identify_id)
|
|
202
|
+
if cached is not None:
|
|
203
|
+
return cached
|
|
187
204
|
ctx = self.for_user(identify_id, session_id)
|
|
188
205
|
ctx.identify(identify_id)
|
|
189
206
|
return ctx
|
|
@@ -195,6 +212,26 @@ class TrodoClient:
|
|
|
195
212
|
self._user_context_cache.pop(distinct_id, None)
|
|
196
213
|
return self.for_user(distinct_id).reset()
|
|
197
214
|
|
|
215
|
+
def upsert_user(
|
|
216
|
+
self,
|
|
217
|
+
distinct_id: str,
|
|
218
|
+
*,
|
|
219
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
220
|
+
set_once: Optional[Dict[str, Any]] = None,
|
|
221
|
+
fixed_properties: Optional[Dict[str, Any]] = None,
|
|
222
|
+
) -> ApiResult:
|
|
223
|
+
"""Create-or-update a user and their traits in one idempotent call.
|
|
224
|
+
|
|
225
|
+
Flat alias for ``client.users.upsert(...)``, matching the ``people_set``
|
|
226
|
+
convention.
|
|
227
|
+
"""
|
|
228
|
+
return self.users.upsert(
|
|
229
|
+
distinct_id,
|
|
230
|
+
properties=properties,
|
|
231
|
+
set_once=set_once,
|
|
232
|
+
fixed_properties=fixed_properties,
|
|
233
|
+
)
|
|
234
|
+
|
|
198
235
|
# People (direct)
|
|
199
236
|
def people_set(self, distinct_id: str, properties: Dict[str, Any]) -> ApiResult:
|
|
200
237
|
return self.for_user(distinct_id).people.set(properties)
|
|
@@ -289,11 +326,19 @@ class TrodoClient:
|
|
|
289
326
|
conversation_id: Optional[str] = None,
|
|
290
327
|
parent_run_id: Optional[str] = None,
|
|
291
328
|
metadata: Optional[Dict[str, Any]] = None,
|
|
329
|
+
user: Optional[Dict[str, Any]] = None,
|
|
292
330
|
) -> wrap_agent_ctx:
|
|
293
331
|
"""Context manager that captures the wrapped block as an agent run.
|
|
294
332
|
|
|
295
333
|
Every OTel-instrumented call (Anthropic, OpenAI, LangChain…) made
|
|
296
334
|
inside the ``with`` is auto-captured as a nested span.
|
|
335
|
+
|
|
336
|
+
``user`` optionally carries profile traits — ``{"properties": ...,
|
|
337
|
+
"set_once": ..., "fixed_properties": ...}`` — so a stateless request
|
|
338
|
+
handler can attribute the run AND enrich the profile in one round trip
|
|
339
|
+
instead of a separate ``upsert_user`` call it has to sequence
|
|
340
|
+
correctly. Applied server-side and non-fatally: a failed trait write
|
|
341
|
+
never costs you the trace. Use ``upsert_user`` when you need the result.
|
|
297
342
|
"""
|
|
298
343
|
return wrap_agent_ctx(
|
|
299
344
|
processor=self._span_processor,
|
|
@@ -303,6 +348,7 @@ class TrodoClient:
|
|
|
303
348
|
conversation_id=conversation_id,
|
|
304
349
|
parent_run_id=parent_run_id,
|
|
305
350
|
metadata=metadata,
|
|
351
|
+
user=user,
|
|
306
352
|
)
|
|
307
353
|
|
|
308
354
|
def agent(
|
|
@@ -352,10 +398,13 @@ class TrodoClient:
|
|
|
352
398
|
parent_run_id: Optional[str] = None,
|
|
353
399
|
metadata: Optional[Dict[str, Any]] = None,
|
|
354
400
|
input: Any = None,
|
|
401
|
+
user: Optional[Dict[str, Any]] = None,
|
|
355
402
|
) -> str:
|
|
356
403
|
"""Open a Run record outside a context manager. Returns the run_id.
|
|
357
404
|
|
|
358
405
|
Use ``end_run`` to finalise, ``join_run`` from any process to add spans.
|
|
406
|
+
|
|
407
|
+
``user`` carries profile traits alongside the run — see ``wrap_agent``.
|
|
359
408
|
"""
|
|
360
409
|
return start_run_fn(
|
|
361
410
|
processor=self._span_processor,
|
|
@@ -366,6 +415,7 @@ class TrodoClient:
|
|
|
366
415
|
parent_run_id=parent_run_id,
|
|
367
416
|
metadata=metadata,
|
|
368
417
|
input=input,
|
|
418
|
+
user=user,
|
|
369
419
|
)
|
|
370
420
|
|
|
371
421
|
def end_run(
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""User upsert — create-or-update a user and their traits in one call.
|
|
2
|
+
|
|
3
|
+
Backend services are stateless: a request handler knows who the user is and a
|
|
4
|
+
few things about them for the duration of one request, and holds nothing between
|
|
5
|
+
requests. This is the call that expresses that.
|
|
6
|
+
|
|
7
|
+
trodo.upsert_user(
|
|
8
|
+
"user-42",
|
|
9
|
+
properties={"plan": "pro"},
|
|
10
|
+
set_once={"signup_date": "2026-01-04"},
|
|
11
|
+
fixed_properties={"last_location_country": "India"},
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
Every bag is optional. With none of them it still ensures the user exists —
|
|
15
|
+
useful at signup, before they have produced any agent runs. Safe to call on
|
|
16
|
+
every request: it is idempotent, and ``set_once`` keys are only written the
|
|
17
|
+
first time.
|
|
18
|
+
|
|
19
|
+
Mirrors ``sdks/trodo-node-sdk/src/managers/UserManager.ts``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Dict, Optional
|
|
25
|
+
|
|
26
|
+
from ..types import ApiResult
|
|
27
|
+
|
|
28
|
+
__all__ = ["UserManager"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UserManager:
|
|
32
|
+
def __init__(self, http_client: object) -> None:
|
|
33
|
+
self._http = http_client
|
|
34
|
+
|
|
35
|
+
def upsert(
|
|
36
|
+
self,
|
|
37
|
+
distinct_id: str,
|
|
38
|
+
*,
|
|
39
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
40
|
+
set_once: Optional[Dict[str, Any]] = None,
|
|
41
|
+
fixed_properties: Optional[Dict[str, Any]] = None,
|
|
42
|
+
) -> ApiResult:
|
|
43
|
+
"""Create or update ``distinct_id`` and apply the supplied traits.
|
|
44
|
+
|
|
45
|
+
:param distinct_id: The user's id — the same value passed as
|
|
46
|
+
``distinct_id`` to ``wrap_agent``, and the same one the browser SDK
|
|
47
|
+
calls ``identify()`` with. Matching them is what merges a user's
|
|
48
|
+
agent runs and product events into one profile.
|
|
49
|
+
:param properties: Merged into the user's custom properties.
|
|
50
|
+
:param set_once: Written only where the key is absent.
|
|
51
|
+
:param fixed_properties: Allowlisted real columns —
|
|
52
|
+
``first/last_location_country`` and ``first/last_location_city``.
|
|
53
|
+
Device, browser, OS, referrer and UTM are browser observations and
|
|
54
|
+
come back in ``skipped`` rather than being stored; a backend
|
|
55
|
+
supplying them is guessing.
|
|
56
|
+
|
|
57
|
+
:returns: ``{"success", "distinctId", "created", "applied", "skipped"}``.
|
|
58
|
+
A skipped fixed key is a normal outcome (browser-owned or
|
|
59
|
+
browser-only), not an error.
|
|
60
|
+
"""
|
|
61
|
+
# Absent bags are omitted rather than sent as null: the server
|
|
62
|
+
# distinguishes "no bag" from "empty bag" by presence.
|
|
63
|
+
payload: Dict[str, Any] = {"distinctId": distinct_id}
|
|
64
|
+
if properties is not None:
|
|
65
|
+
payload["properties"] = properties
|
|
66
|
+
if set_once is not None:
|
|
67
|
+
payload["setOnce"] = set_once
|
|
68
|
+
if fixed_properties is not None:
|
|
69
|
+
payload["fixedProperties"] = fixed_properties
|
|
70
|
+
return self._http.post_user_upsert(payload) # type: ignore[attr-defined]
|
|
@@ -36,6 +36,10 @@ class TrodoRun:
|
|
|
36
36
|
# Free-form run-level attributes (e.g. {"trodo.prompts": [...]} — the set of
|
|
37
37
|
# managed-prompt versions used across the run, for prompt traceability).
|
|
38
38
|
attributes: Optional[Dict[str, Any]] = None
|
|
39
|
+
# Optional user traits applied alongside the run (see wrap_agent's `user`
|
|
40
|
+
# kwarg). Forwarded verbatim to the backend, which applies them
|
|
41
|
+
# non-fatally — this is not a run column.
|
|
42
|
+
user: Optional[Dict[str, Any]] = None
|
|
39
43
|
# Aggregates summed from child spans at finalisation.
|
|
40
44
|
total_tokens_in: Optional[int] = None
|
|
41
45
|
total_tokens_out: Optional[int] = None
|
|
@@ -212,6 +212,30 @@ def _aggregate(spans: list[TrodoSpan]) -> Dict[str, Any]:
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
|
|
215
|
+
def _user_bag(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
216
|
+
"""Normalise a user-traits bag for the wire, or None when it carries
|
|
217
|
+
nothing.
|
|
218
|
+
|
|
219
|
+
Accepts both snake_case (``set_once`` / ``fixed_properties``, natural in
|
|
220
|
+
Python) and the camelCase the wire uses, so callers can write either.
|
|
221
|
+
Omitting an empty bag keeps the payload honest — the backend distinguishes
|
|
222
|
+
"no traits" from "empty traits" by presence.
|
|
223
|
+
"""
|
|
224
|
+
if not isinstance(user, dict):
|
|
225
|
+
return None
|
|
226
|
+
out: Dict[str, Any] = {}
|
|
227
|
+
props = user.get("properties")
|
|
228
|
+
once = user.get("set_once", user.get("setOnce"))
|
|
229
|
+
fixed = user.get("fixed_properties", user.get("fixedProperties"))
|
|
230
|
+
if isinstance(props, dict) and props:
|
|
231
|
+
out["properties"] = props
|
|
232
|
+
if isinstance(once, dict) and once:
|
|
233
|
+
out["setOnce"] = once
|
|
234
|
+
if isinstance(fixed, dict) and fixed:
|
|
235
|
+
out["fixedProperties"] = fixed
|
|
236
|
+
return out or None
|
|
237
|
+
|
|
238
|
+
|
|
215
239
|
def _mint_anon_distinct_id() -> str:
|
|
216
240
|
"""Mint a server-side anonymous distinct_id for an agent run.
|
|
217
241
|
|
|
@@ -420,6 +444,7 @@ def start_run(
|
|
|
420
444
|
parent_run_id: Optional[str] = None,
|
|
421
445
|
metadata: Optional[Dict[str, Any]] = None,
|
|
422
446
|
input: Any = None,
|
|
447
|
+
user: Optional[Dict[str, Any]] = None,
|
|
423
448
|
) -> str:
|
|
424
449
|
"""Open a Run record without holding a context manager.
|
|
425
450
|
|
|
@@ -448,6 +473,7 @@ def start_run(
|
|
|
448
473
|
input=_prepare_value(input),
|
|
449
474
|
started_at=_now_iso(),
|
|
450
475
|
metadata=metadata,
|
|
476
|
+
user=_user_bag(user),
|
|
451
477
|
)
|
|
452
478
|
processor.mark_joined(rid)
|
|
453
479
|
processor.start_run(run)
|
|
@@ -503,6 +529,7 @@ class wrap_agent:
|
|
|
503
529
|
conversation_id: Optional[str] = None,
|
|
504
530
|
parent_run_id: Optional[str] = None,
|
|
505
531
|
metadata: Optional[Dict[str, Any]] = None,
|
|
532
|
+
user: Optional[Dict[str, Any]] = None,
|
|
506
533
|
) -> None:
|
|
507
534
|
self._processor = processor
|
|
508
535
|
self._team_site_id = team_site_id
|
|
@@ -515,6 +542,7 @@ class wrap_agent:
|
|
|
515
542
|
self._conversation_id = conversation_id
|
|
516
543
|
self._parent_run_id = parent_run_id
|
|
517
544
|
self._metadata = metadata
|
|
545
|
+
self._user = _user_bag(user)
|
|
518
546
|
self._ctx_mgr: Optional[run_with_context] = None
|
|
519
547
|
self._ctx: Optional[ActiveSpanContext] = None
|
|
520
548
|
self._started_ms: float = 0.0
|
|
@@ -600,6 +628,9 @@ class wrap_agent:
|
|
|
600
628
|
error_summary=error_summary,
|
|
601
629
|
error_type=error_type,
|
|
602
630
|
metadata={**(self._metadata or {}), **self.handle.metadata} or None,
|
|
631
|
+
# Traits travel on the failure path too — a run that threw is
|
|
632
|
+
# exactly when you still want to know who the user was.
|
|
633
|
+
user=self._user,
|
|
603
634
|
attributes=(
|
|
604
635
|
{"trodo.prompts": self._ctx.prompt_state["all"]}
|
|
605
636
|
if self._ctx is not None
|
|
@@ -6,12 +6,17 @@ import threading
|
|
|
6
6
|
from typing import Dict, Optional
|
|
7
7
|
|
|
8
8
|
from ..types import ServerSession
|
|
9
|
+
from ..util.lru import DEFAULT_MAX_SIZE, LruDict
|
|
9
10
|
from .server_session import build_session_payload, create_server_session
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class SessionManager:
|
|
13
|
-
def __init__(self) -> None:
|
|
14
|
-
|
|
14
|
+
def __init__(self, max_cached_users: int = DEFAULT_MAX_SIZE) -> None:
|
|
15
|
+
# LRU-bounded: one entry per distinct id, and a long-lived backend can
|
|
16
|
+
# see unbounded distinct ids. Evicted users are rebuilt on next use, and
|
|
17
|
+
# the server session id is deterministic (``server:{distinct_id}``), so a
|
|
18
|
+
# rebuild resolves to the same session rather than creating a duplicate.
|
|
19
|
+
self._sessions: LruDict[str, ServerSession] = LruDict(max_cached_users)
|
|
15
20
|
self._lock = threading.Lock()
|
|
16
21
|
self._confirmation_locks: Dict[str, threading.Event] = {}
|
|
17
22
|
self._confirmation_started: Dict[str, bool] = {}
|
|
@@ -23,10 +28,11 @@ class SessionManager:
|
|
|
23
28
|
session_id: Optional[str] = None,
|
|
24
29
|
) -> ServerSession:
|
|
25
30
|
with self._lock:
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
existing = self._sessions.get(distinct_id)
|
|
32
|
+
if existing is not None:
|
|
33
|
+
return existing
|
|
28
34
|
session = create_server_session(site_id, distinct_id, session_id)
|
|
29
|
-
self._sessions
|
|
35
|
+
self._sessions.set(distinct_id, session)
|
|
30
36
|
return session
|
|
31
37
|
|
|
32
38
|
def ensure_confirmed(self, session: ServerSession, http_client: object) -> None:
|
|
@@ -45,13 +45,16 @@ class UserContext:
|
|
|
45
45
|
self._batch_flusher = batch_flusher
|
|
46
46
|
self._auto_event_manager = auto_event_manager
|
|
47
47
|
self._session_id_override = session_id
|
|
48
|
+
# Created on first use, not here.
|
|
49
|
+
#
|
|
50
|
+
# The session used to be built eagerly, which meant
|
|
51
|
+
# ``for_user(id).people.set(...)`` constructed a session object it never
|
|
52
|
+
# sent or read — pure waste on the stateless-backend path, where setting
|
|
53
|
+
# a property on a user you will not otherwise touch is a common call.
|
|
54
|
+
# Sessions are now created by the first operation that needs one
|
|
55
|
+
# (track / identify / wallet_address / capture_error).
|
|
48
56
|
self._session: Optional["ServerSession"] = None
|
|
49
57
|
|
|
50
|
-
# Eagerly initialise session
|
|
51
|
-
self._session = self._session_manager.get_or_create(
|
|
52
|
-
distinct_id, site_id, session_id
|
|
53
|
-
)
|
|
54
|
-
|
|
55
58
|
self.people = PeopleManager(
|
|
56
59
|
http_client, site_id, lambda: self._get_distinct_id()
|
|
57
60
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal utilities. Not part of the public API."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""A dict with a size ceiling that evicts least-recently-used entries.
|
|
2
|
+
|
|
3
|
+
The SDK caches one context and one session per distinct id so repeated calls for
|
|
4
|
+
the same user reuse them. Unbounded, that is a slow leak in exactly the
|
|
5
|
+
deployment this SDK targets: a long-lived API server whose user cardinality
|
|
6
|
+
grows without limit. A worker touching ten users never noticed; a service
|
|
7
|
+
touching a million did.
|
|
8
|
+
|
|
9
|
+
Eviction is safe because every cached value is reconstructible — an evicted user
|
|
10
|
+
is rebuilt on next use, and server session ids are deterministic
|
|
11
|
+
(``server:{distinct_id}``), so rebuilding produces the same session rather than a
|
|
12
|
+
duplicate. The only cost of a miss is re-creating a small object.
|
|
13
|
+
|
|
14
|
+
Backed by :class:`collections.OrderedDict`, whose ``move_to_end`` gives the
|
|
15
|
+
recency ordering directly. Guarded by a lock because backend SDK users routinely
|
|
16
|
+
call from a thread pool.
|
|
17
|
+
|
|
18
|
+
Mirrors ``sdks/trodo-node-sdk/src/util/LruMap.ts``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
from collections import OrderedDict
|
|
25
|
+
from typing import Any, Generic, Optional, TypeVar
|
|
26
|
+
|
|
27
|
+
__all__ = ["LruDict"]
|
|
28
|
+
|
|
29
|
+
K = TypeVar("K")
|
|
30
|
+
V = TypeVar("V")
|
|
31
|
+
|
|
32
|
+
DEFAULT_MAX_SIZE = 10_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LruDict(Generic[K, V]):
|
|
36
|
+
def __init__(self, max_size: int = DEFAULT_MAX_SIZE) -> None:
|
|
37
|
+
# A non-positive cap would evict on every set and defeat the cache;
|
|
38
|
+
# treat it as "no caching is not a supported mode".
|
|
39
|
+
self._max_size = max_size if isinstance(max_size, int) and max_size > 0 else DEFAULT_MAX_SIZE
|
|
40
|
+
self._data: "OrderedDict[K, V]" = OrderedDict()
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def get(self, key: K) -> Optional[V]:
|
|
44
|
+
with self._lock:
|
|
45
|
+
if key not in self._data:
|
|
46
|
+
return None
|
|
47
|
+
self._data.move_to_end(key)
|
|
48
|
+
return self._data[key]
|
|
49
|
+
|
|
50
|
+
def set(self, key: K, value: V) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._data[key] = value
|
|
53
|
+
self._data.move_to_end(key)
|
|
54
|
+
while len(self._data) > self._max_size:
|
|
55
|
+
self._data.popitem(last=False)
|
|
56
|
+
|
|
57
|
+
def pop(self, key: K, default: Any = None) -> Any:
|
|
58
|
+
with self._lock:
|
|
59
|
+
return self._data.pop(key, default)
|
|
60
|
+
|
|
61
|
+
def clear(self) -> None:
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._data.clear()
|
|
64
|
+
|
|
65
|
+
def __contains__(self, key: object) -> bool:
|
|
66
|
+
with self._lock:
|
|
67
|
+
return key in self._data
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
with self._lock:
|
|
71
|
+
return len(self._data)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trodo-python
|
|
3
|
-
Version: 2.
|
|
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
|
|
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
|
|
|
@@ -13,6 +13,7 @@ tests/test_prompt_trace.py
|
|
|
13
13
|
tests/test_prompts.py
|
|
14
14
|
tests/test_register_otel.py
|
|
15
15
|
tests/test_start_run.py
|
|
16
|
+
tests/test_user_traits.py
|
|
16
17
|
tests/test_wrap_agent_unchanged.py
|
|
17
18
|
trodo/__init__.py
|
|
18
19
|
trodo/client.py
|
|
@@ -30,6 +31,7 @@ trodo/managers/experiment_manager.py
|
|
|
30
31
|
trodo/managers/group_manager.py
|
|
31
32
|
trodo/managers/people_manager.py
|
|
32
33
|
trodo/managers/prompt_manager.py
|
|
34
|
+
trodo/managers/user_manager.py
|
|
33
35
|
trodo/otel/__init__.py
|
|
34
36
|
trodo/otel/auto_instrument.py
|
|
35
37
|
trodo/otel/context.py
|
|
@@ -49,6 +51,8 @@ trodo/queue/event_queue.py
|
|
|
49
51
|
trodo/session/__init__.py
|
|
50
52
|
trodo/session/server_session.py
|
|
51
53
|
trodo/session/session_manager.py
|
|
54
|
+
trodo/util/__init__.py
|
|
55
|
+
trodo/util/lru.py
|
|
52
56
|
trodo_python.egg-info/PKG-INFO
|
|
53
57
|
trodo_python.egg-info/SOURCES.txt
|
|
54
58
|
trodo_python.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|