tracelens-sdk 1.0.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.
- tracelens_sdk-1.0.0/PKG-INFO +202 -0
- tracelens_sdk-1.0.0/README.md +180 -0
- tracelens_sdk-1.0.0/pyproject.toml +37 -0
- tracelens_sdk-1.0.0/setup.cfg +4 -0
- tracelens_sdk-1.0.0/tests/test_adapters.py +58 -0
- tracelens_sdk-1.0.0/tests/test_tracer.py +44 -0
- tracelens_sdk-1.0.0/tracelens/__init__.py +14 -0
- tracelens_sdk-1.0.0/tracelens/adapters/__init__.py +7 -0
- tracelens_sdk-1.0.0/tracelens/adapters/langchain.py +216 -0
- tracelens_sdk-1.0.0/tracelens/adapters/llamaindex.py +51 -0
- tracelens_sdk-1.0.0/tracelens/batcher.py +84 -0
- tracelens_sdk-1.0.0/tracelens/span.py +49 -0
- tracelens_sdk-1.0.0/tracelens/tracer.py +47 -0
- tracelens_sdk-1.0.0/tracelens/types.py +36 -0
- tracelens_sdk-1.0.0/tracelens_sdk.egg-info/PKG-INFO +202 -0
- tracelens_sdk-1.0.0/tracelens_sdk.egg-info/SOURCES.txt +17 -0
- tracelens_sdk-1.0.0/tracelens_sdk.egg-info/dependency_links.txt +1 -0
- tracelens_sdk-1.0.0/tracelens_sdk.egg-info/requires.txt +1 -0
- tracelens_sdk-1.0.0/tracelens_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tracelens-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python client for TraceLens agent-run tracing
|
|
5
|
+
Author-email: Vikas Budde <vikas.budde@hotmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pisigmac/tracelens
|
|
8
|
+
Project-URL: Repository, https://github.com/pisigmac/tracelens
|
|
9
|
+
Project-URL: Issues, https://github.com/pisigmac/tracelens/issues
|
|
10
|
+
Keywords: tracing,observability,llm,agents,tracelens
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.27.0
|
|
22
|
+
|
|
23
|
+
# tracelens-sdk
|
|
24
|
+
|
|
25
|
+
Python client for [TraceLens](https://github.com/pisigmac/tracelens), a local tracing stack for AI agent runs.
|
|
26
|
+
|
|
27
|
+
The package name on PyPI is `tracelens-sdk`. The import name is `tracelens`. `tracelens` is already another project, and PyPI rejects `trace-lens` as too similar to it.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install tracelens-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.9+ and `httpx`.
|
|
34
|
+
|
|
35
|
+
A `TraceLens` client turns a unit of work into a span, batches completed spans, and posts them to a TraceLens collector at `POST /v1/spans`. The collector stores the batch in ClickHouse. The dashboard reads it back.
|
|
36
|
+
|
|
37
|
+
You bring the collector. This package does not start one. The repository `docker-compose.yml` runs ClickHouse, the collector on port `8080`, and the dashboard on port `43000`.
|
|
38
|
+
|
|
39
|
+
## Authentication
|
|
40
|
+
|
|
41
|
+
`api_key` is sent as `Authorization: Bearer <api_key>`. The collector treats that value as a JWT, not as an opaque string.
|
|
42
|
+
|
|
43
|
+
For the Compose stack the signing secret is `dev-secret-change-in-production`. The token must use HS256 and include `iss=tracelens` and `aud=tracelens-api`.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import base64, hashlib, hmac, json, time
|
|
47
|
+
|
|
48
|
+
def dev_token(secret="dev-secret-change-in-production", ttl=86400):
|
|
49
|
+
def b64(raw: bytes) -> str:
|
|
50
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
51
|
+
|
|
52
|
+
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
|
53
|
+
payload = b64(json.dumps({
|
|
54
|
+
"sub": "local-dev",
|
|
55
|
+
"api_key": "local-dev",
|
|
56
|
+
"tier": "dev",
|
|
57
|
+
"iss": "tracelens",
|
|
58
|
+
"aud": "tracelens-api",
|
|
59
|
+
"iat": int(time.time()),
|
|
60
|
+
"exp": int(time.time()) + ttl,
|
|
61
|
+
}, separators=(",", ":")).encode())
|
|
62
|
+
signing = f"{header}.{payload}".encode()
|
|
63
|
+
sig = b64(hmac.new(secret.encode(), signing, hashlib.sha256).digest())
|
|
64
|
+
return f"{header}.{payload}.{sig}"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use your own secret outside local Compose. Do not ship the development secret.
|
|
68
|
+
|
|
69
|
+
## Record spans
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
from tracelens import TraceLens
|
|
74
|
+
|
|
75
|
+
tl = TraceLens(
|
|
76
|
+
endpoint="http://localhost:8080",
|
|
77
|
+
api_key=dev_token(),
|
|
78
|
+
service="support-agent",
|
|
79
|
+
buffer_ms=100,
|
|
80
|
+
max_batch_size=100,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
span = tl.start_span(
|
|
85
|
+
trace_id="trace-001",
|
|
86
|
+
agent="support-agent",
|
|
87
|
+
task="draft-reply",
|
|
88
|
+
)
|
|
89
|
+
span.set_attribute("llm.model", "claude-3-5-sonnet")
|
|
90
|
+
span.set_attribute("llm.input_tokens", 4200)
|
|
91
|
+
span.set_attribute("llm.output_tokens", 890)
|
|
92
|
+
span.set_attribute("cost.usd", 0.12)
|
|
93
|
+
span.set_attribute("prompt", "Summarise the ticket")
|
|
94
|
+
ended = span.end(status="ok")
|
|
95
|
+
|
|
96
|
+
follow_up = tl.start_span(
|
|
97
|
+
trace_id="trace-001",
|
|
98
|
+
agent="support-agent",
|
|
99
|
+
task="send-reply",
|
|
100
|
+
parent_id=ended.span_id,
|
|
101
|
+
)
|
|
102
|
+
follow_up.end(status="error", error_message="smtp timeout")
|
|
103
|
+
await tl.flush()
|
|
104
|
+
|
|
105
|
+
asyncio.run(main())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`start_span` returns a `Span` immediately. `end(status="ok")` or `end(status="error", error_message=...)` freezes the span, sets `latency_ms` from the wall clock, and queues it. Calling `end()` twice raises `RuntimeError`.
|
|
109
|
+
|
|
110
|
+
`await flush()` sends every queued batch and closes the HTTP client. Call it before the process exits. Spans left in the buffer are not delivered.
|
|
111
|
+
|
|
112
|
+
`service` is accepted on the client and is not written onto the span. Identity in the stored trace comes from `trace_id`, `agent`, and `task`.
|
|
113
|
+
|
|
114
|
+
`buffer_ms` is how long a batch waits before a flush when an event loop is already running. `max_batch_size` flushes early once that many spans are queued. The collector rejects a batch larger than 1000 spans.
|
|
115
|
+
|
|
116
|
+
## Fields the collector indexes
|
|
117
|
+
|
|
118
|
+
`end()` copies four attributes onto dedicated columns. Other attributes are stored on the span as JSON.
|
|
119
|
+
|
|
120
|
+
| `set_attribute` key | Stored column | Default |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `llm.model` | `llm_model` | `null` |
|
|
123
|
+
| `llm.input_tokens` | `input_tokens` | `0` |
|
|
124
|
+
| `llm.output_tokens` | `output_tokens` | `0` |
|
|
125
|
+
| `cost.usd` | `cost_usd` | `0.0` |
|
|
126
|
+
|
|
127
|
+
`agent` is stored as `agent_type`. `task` is stored as `tool_name`. `status` must be `ok` or `error`.
|
|
128
|
+
|
|
129
|
+
Useful free-form attributes, if you want replay to show them, are `prompt`, `response`, `tool_output`, and `decision`. Replay reads those keys and ignores the rest.
|
|
130
|
+
|
|
131
|
+
## Parent spans
|
|
132
|
+
|
|
133
|
+
Pass the parent id yourself. The span id exists only after `end()`.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
parent = tl.start_span(trace_id="trace-001", agent="planner", task="plan")
|
|
137
|
+
parent_data = parent.end(status="ok")
|
|
138
|
+
child = tl.start_span(
|
|
139
|
+
trace_id="trace-001",
|
|
140
|
+
agent="planner",
|
|
141
|
+
task="search",
|
|
142
|
+
parent_id=parent_data.span_id,
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## LangChain callback
|
|
147
|
+
|
|
148
|
+
`TraceLensLangChainHandler` implements the callback method names LangChain uses for chains, chat models, and tools: `on_chain_start`, `on_chain_end`, `on_chain_error`, `on_llm_start`, `on_llm_end`, `on_llm_error`, `on_tool_start`, `on_tool_end`, `on_tool_error`. It is not a subclass of LangChain's `BaseCallbackHandler`. Pass it where a callback object with those methods is accepted.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from tracelens import TraceLens, TraceLensLangChainHandler
|
|
152
|
+
|
|
153
|
+
tl = TraceLens(endpoint="http://localhost:8080", api_key=dev_token(), service="support-agent")
|
|
154
|
+
handler = TraceLensLangChainHandler(tl, trace_id="trace-lc-001", agent_name="support-agent")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
On `on_llm_end`, token counts are read from `response.llm_output["token_usage"]` (`prompt_tokens` and `completion_tokens`). Cost is then estimated as `$3 / 1M` input tokens and `$15 / 1M` output tokens. That estimate is not provider pricing. Prompts, outputs, and tool payloads are truncated to 1000 characters.
|
|
158
|
+
|
|
159
|
+
One handler shares a single `trace_id`. Create a new handler per run if each run should be its own trace.
|
|
160
|
+
|
|
161
|
+
## LlamaIndex helper
|
|
162
|
+
|
|
163
|
+
`TraceLensLlamaIndexHandler` is a small helper you call yourself. It is not a LlamaIndex `CallbackHandler` and it does not register with a callback manager.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from tracelens import TraceLens, TraceLensLlamaIndexHandler
|
|
167
|
+
|
|
168
|
+
tl = TraceLens(endpoint="http://localhost:8080", api_key=dev_token(), service="rag")
|
|
169
|
+
rag = TraceLensLlamaIndexHandler(tl, trace_id="trace-rag-001")
|
|
170
|
+
|
|
171
|
+
rag.on_retrieve_start("quarterly revenue", event_id="ret-1")
|
|
172
|
+
rag.on_retrieve_end(nodes, event_id="ret-1")
|
|
173
|
+
|
|
174
|
+
rag.on_query_start("quarterly revenue", event_id="q-1")
|
|
175
|
+
rag.on_query_end(response, event_id="q-1")
|
|
176
|
+
await tl.flush()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`on_retrieve_end` records `retrieved_chunks_count`. `on_query_end` records `response`, truncated to 1000 characters.
|
|
180
|
+
|
|
181
|
+
## What the client does not do
|
|
182
|
+
|
|
183
|
+
- It does not create the collector, the database, or the dashboard.
|
|
184
|
+
- It does not retry a failed post. A non-201 response or a connection error is printed and the batch is dropped.
|
|
185
|
+
- It does not generate `trace_id`. You pass one per run.
|
|
186
|
+
- It does not read traces back. Search, replay, and metrics are collector routes. See the repository README.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
From `sdk/python`, with `pytest` installed:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python3 -m pip install -e .
|
|
194
|
+
python3 -m pytest
|
|
195
|
+
python3 -m build
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The package metadata lives in `pyproject.toml`. Tests live in `tests/` and are not part of the installed package.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT. Copyright (c) 2026 pisigmac.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# tracelens-sdk
|
|
2
|
+
|
|
3
|
+
Python client for [TraceLens](https://github.com/pisigmac/tracelens), a local tracing stack for AI agent runs.
|
|
4
|
+
|
|
5
|
+
The package name on PyPI is `tracelens-sdk`. The import name is `tracelens`. `tracelens` is already another project, and PyPI rejects `trace-lens` as too similar to it.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tracelens-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.9+ and `httpx`.
|
|
12
|
+
|
|
13
|
+
A `TraceLens` client turns a unit of work into a span, batches completed spans, and posts them to a TraceLens collector at `POST /v1/spans`. The collector stores the batch in ClickHouse. The dashboard reads it back.
|
|
14
|
+
|
|
15
|
+
You bring the collector. This package does not start one. The repository `docker-compose.yml` runs ClickHouse, the collector on port `8080`, and the dashboard on port `43000`.
|
|
16
|
+
|
|
17
|
+
## Authentication
|
|
18
|
+
|
|
19
|
+
`api_key` is sent as `Authorization: Bearer <api_key>`. The collector treats that value as a JWT, not as an opaque string.
|
|
20
|
+
|
|
21
|
+
For the Compose stack the signing secret is `dev-secret-change-in-production`. The token must use HS256 and include `iss=tracelens` and `aud=tracelens-api`.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import base64, hashlib, hmac, json, time
|
|
25
|
+
|
|
26
|
+
def dev_token(secret="dev-secret-change-in-production", ttl=86400):
|
|
27
|
+
def b64(raw: bytes) -> str:
|
|
28
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
29
|
+
|
|
30
|
+
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
|
31
|
+
payload = b64(json.dumps({
|
|
32
|
+
"sub": "local-dev",
|
|
33
|
+
"api_key": "local-dev",
|
|
34
|
+
"tier": "dev",
|
|
35
|
+
"iss": "tracelens",
|
|
36
|
+
"aud": "tracelens-api",
|
|
37
|
+
"iat": int(time.time()),
|
|
38
|
+
"exp": int(time.time()) + ttl,
|
|
39
|
+
}, separators=(",", ":")).encode())
|
|
40
|
+
signing = f"{header}.{payload}".encode()
|
|
41
|
+
sig = b64(hmac.new(secret.encode(), signing, hashlib.sha256).digest())
|
|
42
|
+
return f"{header}.{payload}.{sig}"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use your own secret outside local Compose. Do not ship the development secret.
|
|
46
|
+
|
|
47
|
+
## Record spans
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
from tracelens import TraceLens
|
|
52
|
+
|
|
53
|
+
tl = TraceLens(
|
|
54
|
+
endpoint="http://localhost:8080",
|
|
55
|
+
api_key=dev_token(),
|
|
56
|
+
service="support-agent",
|
|
57
|
+
buffer_ms=100,
|
|
58
|
+
max_batch_size=100,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
span = tl.start_span(
|
|
63
|
+
trace_id="trace-001",
|
|
64
|
+
agent="support-agent",
|
|
65
|
+
task="draft-reply",
|
|
66
|
+
)
|
|
67
|
+
span.set_attribute("llm.model", "claude-3-5-sonnet")
|
|
68
|
+
span.set_attribute("llm.input_tokens", 4200)
|
|
69
|
+
span.set_attribute("llm.output_tokens", 890)
|
|
70
|
+
span.set_attribute("cost.usd", 0.12)
|
|
71
|
+
span.set_attribute("prompt", "Summarise the ticket")
|
|
72
|
+
ended = span.end(status="ok")
|
|
73
|
+
|
|
74
|
+
follow_up = tl.start_span(
|
|
75
|
+
trace_id="trace-001",
|
|
76
|
+
agent="support-agent",
|
|
77
|
+
task="send-reply",
|
|
78
|
+
parent_id=ended.span_id,
|
|
79
|
+
)
|
|
80
|
+
follow_up.end(status="error", error_message="smtp timeout")
|
|
81
|
+
await tl.flush()
|
|
82
|
+
|
|
83
|
+
asyncio.run(main())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`start_span` returns a `Span` immediately. `end(status="ok")` or `end(status="error", error_message=...)` freezes the span, sets `latency_ms` from the wall clock, and queues it. Calling `end()` twice raises `RuntimeError`.
|
|
87
|
+
|
|
88
|
+
`await flush()` sends every queued batch and closes the HTTP client. Call it before the process exits. Spans left in the buffer are not delivered.
|
|
89
|
+
|
|
90
|
+
`service` is accepted on the client and is not written onto the span. Identity in the stored trace comes from `trace_id`, `agent`, and `task`.
|
|
91
|
+
|
|
92
|
+
`buffer_ms` is how long a batch waits before a flush when an event loop is already running. `max_batch_size` flushes early once that many spans are queued. The collector rejects a batch larger than 1000 spans.
|
|
93
|
+
|
|
94
|
+
## Fields the collector indexes
|
|
95
|
+
|
|
96
|
+
`end()` copies four attributes onto dedicated columns. Other attributes are stored on the span as JSON.
|
|
97
|
+
|
|
98
|
+
| `set_attribute` key | Stored column | Default |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `llm.model` | `llm_model` | `null` |
|
|
101
|
+
| `llm.input_tokens` | `input_tokens` | `0` |
|
|
102
|
+
| `llm.output_tokens` | `output_tokens` | `0` |
|
|
103
|
+
| `cost.usd` | `cost_usd` | `0.0` |
|
|
104
|
+
|
|
105
|
+
`agent` is stored as `agent_type`. `task` is stored as `tool_name`. `status` must be `ok` or `error`.
|
|
106
|
+
|
|
107
|
+
Useful free-form attributes, if you want replay to show them, are `prompt`, `response`, `tool_output`, and `decision`. Replay reads those keys and ignores the rest.
|
|
108
|
+
|
|
109
|
+
## Parent spans
|
|
110
|
+
|
|
111
|
+
Pass the parent id yourself. The span id exists only after `end()`.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
parent = tl.start_span(trace_id="trace-001", agent="planner", task="plan")
|
|
115
|
+
parent_data = parent.end(status="ok")
|
|
116
|
+
child = tl.start_span(
|
|
117
|
+
trace_id="trace-001",
|
|
118
|
+
agent="planner",
|
|
119
|
+
task="search",
|
|
120
|
+
parent_id=parent_data.span_id,
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## LangChain callback
|
|
125
|
+
|
|
126
|
+
`TraceLensLangChainHandler` implements the callback method names LangChain uses for chains, chat models, and tools: `on_chain_start`, `on_chain_end`, `on_chain_error`, `on_llm_start`, `on_llm_end`, `on_llm_error`, `on_tool_start`, `on_tool_end`, `on_tool_error`. It is not a subclass of LangChain's `BaseCallbackHandler`. Pass it where a callback object with those methods is accepted.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from tracelens import TraceLens, TraceLensLangChainHandler
|
|
130
|
+
|
|
131
|
+
tl = TraceLens(endpoint="http://localhost:8080", api_key=dev_token(), service="support-agent")
|
|
132
|
+
handler = TraceLensLangChainHandler(tl, trace_id="trace-lc-001", agent_name="support-agent")
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
On `on_llm_end`, token counts are read from `response.llm_output["token_usage"]` (`prompt_tokens` and `completion_tokens`). Cost is then estimated as `$3 / 1M` input tokens and `$15 / 1M` output tokens. That estimate is not provider pricing. Prompts, outputs, and tool payloads are truncated to 1000 characters.
|
|
136
|
+
|
|
137
|
+
One handler shares a single `trace_id`. Create a new handler per run if each run should be its own trace.
|
|
138
|
+
|
|
139
|
+
## LlamaIndex helper
|
|
140
|
+
|
|
141
|
+
`TraceLensLlamaIndexHandler` is a small helper you call yourself. It is not a LlamaIndex `CallbackHandler` and it does not register with a callback manager.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from tracelens import TraceLens, TraceLensLlamaIndexHandler
|
|
145
|
+
|
|
146
|
+
tl = TraceLens(endpoint="http://localhost:8080", api_key=dev_token(), service="rag")
|
|
147
|
+
rag = TraceLensLlamaIndexHandler(tl, trace_id="trace-rag-001")
|
|
148
|
+
|
|
149
|
+
rag.on_retrieve_start("quarterly revenue", event_id="ret-1")
|
|
150
|
+
rag.on_retrieve_end(nodes, event_id="ret-1")
|
|
151
|
+
|
|
152
|
+
rag.on_query_start("quarterly revenue", event_id="q-1")
|
|
153
|
+
rag.on_query_end(response, event_id="q-1")
|
|
154
|
+
await tl.flush()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`on_retrieve_end` records `retrieved_chunks_count`. `on_query_end` records `response`, truncated to 1000 characters.
|
|
158
|
+
|
|
159
|
+
## What the client does not do
|
|
160
|
+
|
|
161
|
+
- It does not create the collector, the database, or the dashboard.
|
|
162
|
+
- It does not retry a failed post. A non-201 response or a connection error is printed and the batch is dropped.
|
|
163
|
+
- It does not generate `trace_id`. You pass one per run.
|
|
164
|
+
- It does not read traces back. Search, replay, and metrics are collector routes. See the repository README.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
From `sdk/python`, with `pytest` installed:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
python3 -m pip install -e .
|
|
172
|
+
python3 -m pytest
|
|
173
|
+
python3 -m build
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The package metadata lives in `pyproject.toml`. Tests live in `tests/` and are not part of the installed package.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT. Copyright (c) 2026 pisigmac.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tracelens-sdk"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python client for TraceLens agent-run tracing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Vikas Budde", email = "vikas.budde@hotmail.com" },
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"httpx>=0.27.0",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["tracing", "observability", "llm", "agents", "tracelens"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 5 - Production/Stable",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Software Development :: Libraries",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/pisigmac/tracelens"
|
|
32
|
+
Repository = "https://github.com/pisigmac/tracelens"
|
|
33
|
+
Issues = "https://github.com/pisigmac/tracelens/issues"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
include = ["tracelens*"]
|
|
37
|
+
exclude = ["tests*"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
from tracelens import TraceLens, TraceLensLangChainHandler, TraceLensLlamaIndexHandler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def tl():
|
|
9
|
+
return TraceLens(
|
|
10
|
+
endpoint='http://localhost:8080',
|
|
11
|
+
api_key='test-key',
|
|
12
|
+
service='test-service',
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_langchain_handler_events(tl):
|
|
17
|
+
handler = TraceLensLangChainHandler(
|
|
18
|
+
tracer=tl,
|
|
19
|
+
trace_id="py-lc-001",
|
|
20
|
+
agent_name="python-langchain",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
run_id_1 = uuid4()
|
|
24
|
+
run_id_2 = uuid4()
|
|
25
|
+
|
|
26
|
+
handler.on_chain_start({"name": "test_chain"}, {"input": "hello"}, run_id=run_id_1)
|
|
27
|
+
handler.on_llm_start({"name": "gpt-4o"}, ["Prompt text"], run_id=run_id_2, parent_run_id=run_id_1)
|
|
28
|
+
|
|
29
|
+
mock_llm_res = MagicMock()
|
|
30
|
+
mock_llm_res.llm_output = {"token_usage": {"prompt_tokens": 120, "completion_tokens": 45}}
|
|
31
|
+
mock_llm_res.generations = [[MagicMock(text="Completion response")]]
|
|
32
|
+
|
|
33
|
+
handler.on_llm_end(mock_llm_res, run_id=run_id_2)
|
|
34
|
+
handler.on_chain_end({"output": "success"}, run_id=run_id_1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_langchain_handler_errors(tl):
|
|
38
|
+
handler = TraceLensLangChainHandler(
|
|
39
|
+
tracer=tl,
|
|
40
|
+
trace_id="py-lc-err",
|
|
41
|
+
)
|
|
42
|
+
run_id = uuid4()
|
|
43
|
+
|
|
44
|
+
handler.on_tool_start({"name": "db_lookup"}, "select *", run_id=run_id)
|
|
45
|
+
handler.on_tool_error(RuntimeError("DB query failed"), run_id=run_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_llamaindex_handler_events(tl):
|
|
49
|
+
handler = TraceLensLlamaIndexHandler(
|
|
50
|
+
tracer=tl,
|
|
51
|
+
trace_id="py-llama-001",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
handler.on_retrieve_start("rag query", "ev-1")
|
|
55
|
+
handler.on_retrieve_end([{"node": 1}], "ev-1")
|
|
56
|
+
|
|
57
|
+
handler.on_query_start("full query", "ev-2")
|
|
58
|
+
handler.on_query_end("answer", "ev-2")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import asyncio
|
|
3
|
+
from tracelens import TraceLens
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def tl():
|
|
8
|
+
return TraceLens(
|
|
9
|
+
endpoint='http://localhost:8080',
|
|
10
|
+
api_key='test-key',
|
|
11
|
+
service='test-service',
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_create_and_end_span(tl):
|
|
16
|
+
span = tl.start_span(trace_id='trace-001', agent='cursor', task='refactor')
|
|
17
|
+
span.set_attribute('llm.model', 'claude-3-5-sonnet')
|
|
18
|
+
span.set_attribute('llm.input_tokens', 4200)
|
|
19
|
+
span.set_attribute('cost.usd', 0.12)
|
|
20
|
+
data = span.end(status='ok')
|
|
21
|
+
|
|
22
|
+
assert data.agent_type == 'cursor'
|
|
23
|
+
assert data.tool_name == 'refactor'
|
|
24
|
+
assert data.llm_model == 'claude-3-5-sonnet'
|
|
25
|
+
assert data.input_tokens == 4200
|
|
26
|
+
assert data.cost_usd == 0.12
|
|
27
|
+
assert data.status == 'ok'
|
|
28
|
+
assert data.latency_ms >= 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_span_cannot_end_twice(tl):
|
|
32
|
+
span = tl.start_span(trace_id='trace-002', agent='browser', task='verify')
|
|
33
|
+
span.end(status='ok')
|
|
34
|
+
with pytest.raises(RuntimeError, match='Span already ended'):
|
|
35
|
+
span.end(status='ok')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parent_id_propagation(tl):
|
|
39
|
+
parent = tl.start_span(trace_id='trace-003', agent='cursor', task='edit')
|
|
40
|
+
parent_data = parent.end(status='ok')
|
|
41
|
+
|
|
42
|
+
child = tl.start_span(trace_id='trace-003', agent='browser', task='verify', parent_id=parent_data.span_id)
|
|
43
|
+
child_data = child.end(status='ok')
|
|
44
|
+
assert child_data.parent_id == parent_data.span_id
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .tracer import TraceLens
|
|
2
|
+
from .span import Span
|
|
3
|
+
from .types import SpanConfig, SDKConfig
|
|
4
|
+
from .adapters import TraceLensLangChainHandler, TraceLensLlamaIndexHandler
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'TraceLens',
|
|
8
|
+
'Span',
|
|
9
|
+
'SpanConfig',
|
|
10
|
+
'SDKConfig',
|
|
11
|
+
'TraceLensLangChainHandler',
|
|
12
|
+
'TraceLensLlamaIndexHandler',
|
|
13
|
+
]
|
|
14
|
+
__version__ = '1.0.0'
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, Dict, List, Optional, Union
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
from tracelens.tracer import TraceLens
|
|
6
|
+
from tracelens.span import Span
|
|
7
|
+
|
|
8
|
+
class TraceLensLangChainHandler:
|
|
9
|
+
"""
|
|
10
|
+
LangChain & LangGraph Callback Handler for TraceLens.
|
|
11
|
+
Captures chains, LLM calls, and tool executions, measuring latencies, token burn, and errors.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
tracer: TraceLens,
|
|
17
|
+
trace_id: Optional[str] = None,
|
|
18
|
+
agent_name: str = "langchain-agent",
|
|
19
|
+
):
|
|
20
|
+
self.tracer = tracer
|
|
21
|
+
self.default_trace_id = trace_id or f"langchain-{int(time.time() * 1000)}"
|
|
22
|
+
self.agent_name = agent_name
|
|
23
|
+
self.active_spans: Dict[str, Dict[str, Any]] = {}
|
|
24
|
+
|
|
25
|
+
def on_chain_start(
|
|
26
|
+
self,
|
|
27
|
+
serialized: Dict[str, Any],
|
|
28
|
+
inputs: Dict[str, Any],
|
|
29
|
+
*,
|
|
30
|
+
run_id: UUID,
|
|
31
|
+
parent_run_id: Optional[UUID] = None,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> None:
|
|
34
|
+
run_key = str(run_id)
|
|
35
|
+
parent_key = str(parent_run_id) if parent_run_id else None
|
|
36
|
+
parent_span_id = self.active_spans.get(parent_key, {}).get("span_id") if parent_key else None
|
|
37
|
+
|
|
38
|
+
task_name = serialized.get("name") or serialized.get("id", ["chain"])[-1] if serialized else "chain_execution"
|
|
39
|
+
|
|
40
|
+
span = self.tracer.start_span(
|
|
41
|
+
trace_id=self.default_trace_id,
|
|
42
|
+
agent=self.agent_name,
|
|
43
|
+
task=task_name,
|
|
44
|
+
parent_id=parent_span_id,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
span.set_attribute("inputs", json.dumps(inputs)[:1000] if inputs else None)
|
|
48
|
+
span.set_attribute("framework", "langchain")
|
|
49
|
+
|
|
50
|
+
self.active_spans[run_key] = {"span": span, "span_id": getattr(span, "span_id", run_key)}
|
|
51
|
+
|
|
52
|
+
def on_chain_end(
|
|
53
|
+
self,
|
|
54
|
+
outputs: Dict[str, Any],
|
|
55
|
+
*,
|
|
56
|
+
run_id: UUID,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> None:
|
|
59
|
+
run_key = str(run_id)
|
|
60
|
+
item = self.active_spans.pop(run_key, None)
|
|
61
|
+
if not item:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
span: Span = item["span"]
|
|
65
|
+
span.set_attribute("outputs", json.dumps(outputs)[:1000] if outputs else None)
|
|
66
|
+
span.end(status="ok")
|
|
67
|
+
|
|
68
|
+
def on_chain_error(
|
|
69
|
+
self,
|
|
70
|
+
error: BaseException,
|
|
71
|
+
*,
|
|
72
|
+
run_id: UUID,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> None:
|
|
75
|
+
run_key = str(run_id)
|
|
76
|
+
item = self.active_spans.pop(run_key, None)
|
|
77
|
+
if not item:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
span: Span = item["span"]
|
|
81
|
+
span.end(status="error", error_message=str(error))
|
|
82
|
+
|
|
83
|
+
def on_llm_start(
|
|
84
|
+
self,
|
|
85
|
+
serialized: Dict[str, Any],
|
|
86
|
+
prompts: List[str],
|
|
87
|
+
*,
|
|
88
|
+
run_id: UUID,
|
|
89
|
+
parent_run_id: Optional[UUID] = None,
|
|
90
|
+
**kwargs: Any,
|
|
91
|
+
) -> None:
|
|
92
|
+
run_key = str(run_id)
|
|
93
|
+
parent_key = str(parent_run_id) if parent_run_id else None
|
|
94
|
+
parent_span_id = self.active_spans.get(parent_key, {}).get("span_id") if parent_key else None
|
|
95
|
+
|
|
96
|
+
model_name = serialized.get("name") or "llm" if serialized else "llm"
|
|
97
|
+
|
|
98
|
+
span = self.tracer.start_span(
|
|
99
|
+
trace_id=self.default_trace_id,
|
|
100
|
+
agent=self.agent_name,
|
|
101
|
+
task=f"llm_inference:{model_name}",
|
|
102
|
+
parent_id=parent_span_id,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
span.set_attribute("llm.model", model_name)
|
|
106
|
+
span.set_attribute("prompt", "\n".join(prompts)[:1000] if prompts else "")
|
|
107
|
+
span.set_attribute("framework", "langchain")
|
|
108
|
+
|
|
109
|
+
self.active_spans[run_key] = {"span": span, "span_id": getattr(span, "span_id", run_key)}
|
|
110
|
+
|
|
111
|
+
def on_llm_end(
|
|
112
|
+
self,
|
|
113
|
+
response: Any,
|
|
114
|
+
*,
|
|
115
|
+
run_id: UUID,
|
|
116
|
+
**kwargs: Any,
|
|
117
|
+
) -> None:
|
|
118
|
+
run_key = str(run_id)
|
|
119
|
+
item = self.active_spans.pop(run_key, None)
|
|
120
|
+
if not item:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
span: Span = item["span"]
|
|
124
|
+
|
|
125
|
+
llm_output = getattr(response, "llm_output", {}) or {}
|
|
126
|
+
token_usage = llm_output.get("token_usage") or llm_output.get("tokenUsage") or {}
|
|
127
|
+
|
|
128
|
+
if token_usage:
|
|
129
|
+
prompt_tokens = token_usage.get("prompt_tokens") or token_usage.get("promptTokens") or 0
|
|
130
|
+
completion_tokens = token_usage.get("completion_tokens") or token_usage.get("completionTokens") or 0
|
|
131
|
+
|
|
132
|
+
span.set_attribute("llm.input_tokens", prompt_tokens)
|
|
133
|
+
span.set_attribute("llm.output_tokens", completion_tokens)
|
|
134
|
+
|
|
135
|
+
cost = (prompt_tokens * 0.000003) + (completion_tokens * 0.000015)
|
|
136
|
+
span.set_attribute("cost.usd", round(cost, 6))
|
|
137
|
+
|
|
138
|
+
generations = getattr(response, "generations", [])
|
|
139
|
+
if generations and len(generations) > 0 and len(generations[0]) > 0:
|
|
140
|
+
gen_text = getattr(generations[0][0], "text", str(generations[0][0]))
|
|
141
|
+
span.set_attribute("response", gen_text[:1000])
|
|
142
|
+
|
|
143
|
+
span.end(status="ok")
|
|
144
|
+
|
|
145
|
+
def on_llm_error(
|
|
146
|
+
self,
|
|
147
|
+
error: BaseException,
|
|
148
|
+
*,
|
|
149
|
+
run_id: UUID,
|
|
150
|
+
**kwargs: Any,
|
|
151
|
+
) -> None:
|
|
152
|
+
run_key = str(run_id)
|
|
153
|
+
item = self.active_spans.pop(run_key, None)
|
|
154
|
+
if not item:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
span: Span = item["span"]
|
|
158
|
+
span.end(status="error", error_message=str(error))
|
|
159
|
+
|
|
160
|
+
def on_tool_start(
|
|
161
|
+
self,
|
|
162
|
+
serialized: Dict[str, Any],
|
|
163
|
+
input_str: str,
|
|
164
|
+
*,
|
|
165
|
+
run_id: UUID,
|
|
166
|
+
parent_run_id: Optional[UUID] = None,
|
|
167
|
+
**kwargs: Any,
|
|
168
|
+
) -> None:
|
|
169
|
+
run_key = str(run_id)
|
|
170
|
+
parent_key = str(parent_run_id) if parent_run_id else None
|
|
171
|
+
parent_span_id = self.active_spans.get(parent_key, {}).get("span_id") if parent_key else None
|
|
172
|
+
|
|
173
|
+
tool_name = serialized.get("name") if serialized else "tool_execution"
|
|
174
|
+
|
|
175
|
+
span = self.tracer.start_span(
|
|
176
|
+
trace_id=self.default_trace_id,
|
|
177
|
+
agent=self.agent_name,
|
|
178
|
+
task=tool_name,
|
|
179
|
+
parent_id=parent_span_id,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
span.set_attribute("tool_input", str(input_str)[:1000])
|
|
183
|
+
span.set_attribute("framework", "langchain")
|
|
184
|
+
|
|
185
|
+
self.active_spans[run_key] = {"span": span, "span_id": getattr(span, "span_id", run_key)}
|
|
186
|
+
|
|
187
|
+
def on_tool_end(
|
|
188
|
+
self,
|
|
189
|
+
output: str,
|
|
190
|
+
*,
|
|
191
|
+
run_id: UUID,
|
|
192
|
+
**kwargs: Any,
|
|
193
|
+
) -> None:
|
|
194
|
+
run_key = str(run_id)
|
|
195
|
+
item = self.active_spans.pop(run_key, None)
|
|
196
|
+
if not item:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
span: Span = item["span"]
|
|
200
|
+
span.set_attribute("tool_output", str(output)[:1000])
|
|
201
|
+
span.end(status="ok")
|
|
202
|
+
|
|
203
|
+
def on_tool_error(
|
|
204
|
+
self,
|
|
205
|
+
error: BaseException,
|
|
206
|
+
*,
|
|
207
|
+
run_id: UUID,
|
|
208
|
+
**kwargs: Any,
|
|
209
|
+
) -> None:
|
|
210
|
+
run_key = str(run_id)
|
|
211
|
+
item = self.active_spans.pop(run_key, None)
|
|
212
|
+
if not item:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
span: Span = item["span"]
|
|
216
|
+
span.end(status="error", error_message=str(error))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from tracelens.tracer import TraceLens
|
|
4
|
+
from tracelens.span import Span
|
|
5
|
+
|
|
6
|
+
class TraceLensLlamaIndexHandler:
|
|
7
|
+
"""
|
|
8
|
+
LlamaIndex Callback Handler for TraceLens.
|
|
9
|
+
Captures vector search, chunk retrieval, and RAG query execution.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, tracer: TraceLens, trace_id: Optional[str] = None):
|
|
13
|
+
self.tracer = tracer
|
|
14
|
+
self.default_trace_id = trace_id or f"llamaindex-{int(time.time() * 1000)}"
|
|
15
|
+
self.active_events: Dict[str, Span] = {}
|
|
16
|
+
|
|
17
|
+
def on_retrieve_start(self, query: str, event_id: str) -> None:
|
|
18
|
+
span = self.tracer.start_span(
|
|
19
|
+
trace_id=self.default_trace_id,
|
|
20
|
+
agent="llamaindex",
|
|
21
|
+
task="vector_retrieval",
|
|
22
|
+
)
|
|
23
|
+
span.set_attribute("query", query)
|
|
24
|
+
span.set_attribute("framework", "llamaindex")
|
|
25
|
+
self.active_events[event_id] = span
|
|
26
|
+
|
|
27
|
+
def on_retrieve_end(self, nodes: List[Any], event_id: str) -> None:
|
|
28
|
+
span = self.active_events.pop(event_id, None)
|
|
29
|
+
if not span:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
span.set_attribute("retrieved_chunks_count", len(nodes) if nodes else 0)
|
|
33
|
+
span.end(status="ok")
|
|
34
|
+
|
|
35
|
+
def on_query_start(self, query: str, event_id: str) -> None:
|
|
36
|
+
span = self.tracer.start_span(
|
|
37
|
+
trace_id=self.default_trace_id,
|
|
38
|
+
agent="llamaindex",
|
|
39
|
+
task="rag_query_execution",
|
|
40
|
+
)
|
|
41
|
+
span.set_attribute("query", query)
|
|
42
|
+
span.set_attribute("framework", "llamaindex")
|
|
43
|
+
self.active_events[event_id] = span
|
|
44
|
+
|
|
45
|
+
def on_query_end(self, response: Any, event_id: str) -> None:
|
|
46
|
+
span = self.active_events.pop(event_id, None)
|
|
47
|
+
if not span:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
span.set_attribute("response", str(response)[:1000])
|
|
51
|
+
span.end(status="ok")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import List, Dict, Any
|
|
4
|
+
from .types import SpanData
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Batcher:
|
|
8
|
+
def __init__(self, endpoint: str, api_key: str, trace_id: str, buffer_ms: int = 100, max_batch_size: int = 100):
|
|
9
|
+
self.endpoint = endpoint
|
|
10
|
+
self.api_key = api_key
|
|
11
|
+
self.trace_id = trace_id
|
|
12
|
+
self.buffer_ms = buffer_ms
|
|
13
|
+
self.max_batch_size = max_batch_size
|
|
14
|
+
self._buffer: List[SpanData] = []
|
|
15
|
+
self._client = httpx.AsyncClient(timeout=10.0)
|
|
16
|
+
self._lock = asyncio.Lock()
|
|
17
|
+
try:
|
|
18
|
+
self._task = asyncio.create_task(self._loop())
|
|
19
|
+
except RuntimeError:
|
|
20
|
+
self._task = None
|
|
21
|
+
|
|
22
|
+
def add_sync(self, span: SpanData) -> None:
|
|
23
|
+
self._buffer.append(span)
|
|
24
|
+
|
|
25
|
+
async def add(self, span: SpanData) -> None:
|
|
26
|
+
async with self._lock:
|
|
27
|
+
self._buffer.append(span)
|
|
28
|
+
if len(self._buffer) >= self.max_batch_size:
|
|
29
|
+
await self._flush()
|
|
30
|
+
|
|
31
|
+
async def _loop(self) -> None:
|
|
32
|
+
while self._running:
|
|
33
|
+
await asyncio.sleep(self.buffer_ms / 1000)
|
|
34
|
+
async with self._lock:
|
|
35
|
+
await self._flush()
|
|
36
|
+
|
|
37
|
+
async def _flush(self) -> None:
|
|
38
|
+
if not self._buffer:
|
|
39
|
+
return
|
|
40
|
+
batch = self._buffer
|
|
41
|
+
self._buffer = []
|
|
42
|
+
payload = {
|
|
43
|
+
'trace_id': self.trace_id,
|
|
44
|
+
'spans': [
|
|
45
|
+
{
|
|
46
|
+
'span_id': s.span_id,
|
|
47
|
+
'parent_id': s.parent_id,
|
|
48
|
+
'agent_type': s.agent_type,
|
|
49
|
+
'tool_name': s.tool_name,
|
|
50
|
+
'llm_model': s.llm_model,
|
|
51
|
+
'input_tokens': s.input_tokens,
|
|
52
|
+
'output_tokens': s.output_tokens,
|
|
53
|
+
'latency_ms': s.latency_ms,
|
|
54
|
+
'status': s.status,
|
|
55
|
+
'error_message': s.error_message,
|
|
56
|
+
'cost_usd': s.cost_usd,
|
|
57
|
+
'timestamp': s.timestamp,
|
|
58
|
+
'attributes': s.attributes,
|
|
59
|
+
}
|
|
60
|
+
for s in batch
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
try:
|
|
64
|
+
resp = await self._client.post(
|
|
65
|
+
f'{self.endpoint}/v1/spans',
|
|
66
|
+
headers={'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json'},
|
|
67
|
+
json=payload,
|
|
68
|
+
)
|
|
69
|
+
if resp.status_code != 201:
|
|
70
|
+
print(f'TraceLens ingest failed: {resp.status_code}')
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f'TraceLens flush error: {e}')
|
|
73
|
+
|
|
74
|
+
async def stop(self) -> None:
|
|
75
|
+
self._running = False
|
|
76
|
+
if self._task:
|
|
77
|
+
self._task.cancel()
|
|
78
|
+
try:
|
|
79
|
+
await self._task
|
|
80
|
+
except asyncio.CancelledError:
|
|
81
|
+
pass
|
|
82
|
+
async with self._lock:
|
|
83
|
+
await self._flush()
|
|
84
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
from .types import SpanConfig, SpanData
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Span:
|
|
8
|
+
def __init__(self, config: SpanConfig):
|
|
9
|
+
self.config = config
|
|
10
|
+
self.attrs: Dict[str, Any] = {}
|
|
11
|
+
self.start_time = time.time()
|
|
12
|
+
self._ended = False
|
|
13
|
+
self.data: Optional[SpanData] = None
|
|
14
|
+
|
|
15
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
16
|
+
if self._ended:
|
|
17
|
+
return
|
|
18
|
+
self.attrs[key] = value
|
|
19
|
+
|
|
20
|
+
def set_attributes(self, attrs: Dict[str, Any]) -> None:
|
|
21
|
+
if self._ended:
|
|
22
|
+
return
|
|
23
|
+
self.attrs.update(attrs)
|
|
24
|
+
|
|
25
|
+
def end(self, status: str = 'ok', error_message: Optional[str] = None) -> SpanData:
|
|
26
|
+
if self._ended:
|
|
27
|
+
raise RuntimeError('Span already ended')
|
|
28
|
+
self._ended = True
|
|
29
|
+
latency = int((time.time() - self.start_time) * 1000)
|
|
30
|
+
|
|
31
|
+
self.data = SpanData(
|
|
32
|
+
span_id=str(uuid.uuid4()),
|
|
33
|
+
parent_id=self.config.parent_id,
|
|
34
|
+
agent_type=self.config.agent,
|
|
35
|
+
tool_name=self.config.task,
|
|
36
|
+
llm_model=self.attrs.get('llm.model'),
|
|
37
|
+
input_tokens=self.attrs.get('llm.input_tokens', 0),
|
|
38
|
+
output_tokens=self.attrs.get('llm.output_tokens', 0),
|
|
39
|
+
latency_ms=latency,
|
|
40
|
+
status=status,
|
|
41
|
+
error_message=error_message,
|
|
42
|
+
cost_usd=self.attrs.get('cost.usd', 0.0),
|
|
43
|
+
timestamp=time.strftime('%Y-%m-%dT%H:%M:%S.') + f'{int((time.time() % 1) * 1000):03d}Z',
|
|
44
|
+
attributes=self.attrs,
|
|
45
|
+
)
|
|
46
|
+
return self.data
|
|
47
|
+
|
|
48
|
+
def is_ended(self) -> bool:
|
|
49
|
+
return self._ended
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from .types import SDKConfig, SpanConfig
|
|
2
|
+
from .span import Span
|
|
3
|
+
from .batcher import Batcher
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TraceLens:
|
|
8
|
+
def __init__(self, endpoint: str, api_key: str, service: str, buffer_ms: int = 100, max_batch_size: int = 100):
|
|
9
|
+
self.config = SDKConfig(
|
|
10
|
+
endpoint=endpoint,
|
|
11
|
+
api_key=api_key,
|
|
12
|
+
service=service,
|
|
13
|
+
buffer_ms=buffer_ms,
|
|
14
|
+
max_batch_size=max_batch_size,
|
|
15
|
+
)
|
|
16
|
+
self._batchers: dict[str, Batcher] = {}
|
|
17
|
+
|
|
18
|
+
def start_span(self, trace_id: str, agent: str, task: str, parent_id: str | None = None) -> Span:
|
|
19
|
+
cfg = SpanConfig(trace_id=trace_id, agent=agent, task=task, parent_id=parent_id)
|
|
20
|
+
span = Span(cfg)
|
|
21
|
+
|
|
22
|
+
if trace_id not in self._batchers:
|
|
23
|
+
self._batchers[trace_id] = Batcher(
|
|
24
|
+
self.config.endpoint,
|
|
25
|
+
self.config.api_key,
|
|
26
|
+
trace_id,
|
|
27
|
+
self.config.buffer_ms,
|
|
28
|
+
self.config.max_batch_size,
|
|
29
|
+
)
|
|
30
|
+
batcher = self._batchers[trace_id]
|
|
31
|
+
|
|
32
|
+
original_end = span.end
|
|
33
|
+
def wrapped_end(status: str = 'ok', error_message: str | None = None):
|
|
34
|
+
data = original_end(status, error_message)
|
|
35
|
+
try:
|
|
36
|
+
loop = asyncio.get_running_loop()
|
|
37
|
+
loop.create_task(batcher.add(data))
|
|
38
|
+
except RuntimeError:
|
|
39
|
+
batcher.add_sync(data)
|
|
40
|
+
return data
|
|
41
|
+
span.end = wrapped_end
|
|
42
|
+
|
|
43
|
+
return span
|
|
44
|
+
|
|
45
|
+
async def flush(self) -> None:
|
|
46
|
+
await asyncio.gather(*(b.stop() for b in self._batchers.values()))
|
|
47
|
+
self._batchers.clear()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, Dict, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class SpanConfig:
|
|
7
|
+
trace_id: str
|
|
8
|
+
agent: str
|
|
9
|
+
task: str
|
|
10
|
+
parent_id: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SDKConfig:
|
|
15
|
+
endpoint: str
|
|
16
|
+
api_key: str
|
|
17
|
+
service: str
|
|
18
|
+
buffer_ms: int = 100
|
|
19
|
+
max_batch_size: int = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SpanData:
|
|
24
|
+
span_id: str
|
|
25
|
+
parent_id: Optional[str]
|
|
26
|
+
agent_type: str
|
|
27
|
+
tool_name: str
|
|
28
|
+
llm_model: Optional[str]
|
|
29
|
+
input_tokens: int
|
|
30
|
+
output_tokens: int
|
|
31
|
+
latency_ms: int
|
|
32
|
+
status: str
|
|
33
|
+
error_message: Optional[str]
|
|
34
|
+
cost_usd: float
|
|
35
|
+
timestamp: str
|
|
36
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tracelens-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python client for TraceLens agent-run tracing
|
|
5
|
+
Author-email: Vikas Budde <vikas.budde@hotmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pisigmac/tracelens
|
|
8
|
+
Project-URL: Repository, https://github.com/pisigmac/tracelens
|
|
9
|
+
Project-URL: Issues, https://github.com/pisigmac/tracelens/issues
|
|
10
|
+
Keywords: tracing,observability,llm,agents,tracelens
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.27.0
|
|
22
|
+
|
|
23
|
+
# tracelens-sdk
|
|
24
|
+
|
|
25
|
+
Python client for [TraceLens](https://github.com/pisigmac/tracelens), a local tracing stack for AI agent runs.
|
|
26
|
+
|
|
27
|
+
The package name on PyPI is `tracelens-sdk`. The import name is `tracelens`. `tracelens` is already another project, and PyPI rejects `trace-lens` as too similar to it.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install tracelens-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.9+ and `httpx`.
|
|
34
|
+
|
|
35
|
+
A `TraceLens` client turns a unit of work into a span, batches completed spans, and posts them to a TraceLens collector at `POST /v1/spans`. The collector stores the batch in ClickHouse. The dashboard reads it back.
|
|
36
|
+
|
|
37
|
+
You bring the collector. This package does not start one. The repository `docker-compose.yml` runs ClickHouse, the collector on port `8080`, and the dashboard on port `43000`.
|
|
38
|
+
|
|
39
|
+
## Authentication
|
|
40
|
+
|
|
41
|
+
`api_key` is sent as `Authorization: Bearer <api_key>`. The collector treats that value as a JWT, not as an opaque string.
|
|
42
|
+
|
|
43
|
+
For the Compose stack the signing secret is `dev-secret-change-in-production`. The token must use HS256 and include `iss=tracelens` and `aud=tracelens-api`.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import base64, hashlib, hmac, json, time
|
|
47
|
+
|
|
48
|
+
def dev_token(secret="dev-secret-change-in-production", ttl=86400):
|
|
49
|
+
def b64(raw: bytes) -> str:
|
|
50
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
51
|
+
|
|
52
|
+
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
|
53
|
+
payload = b64(json.dumps({
|
|
54
|
+
"sub": "local-dev",
|
|
55
|
+
"api_key": "local-dev",
|
|
56
|
+
"tier": "dev",
|
|
57
|
+
"iss": "tracelens",
|
|
58
|
+
"aud": "tracelens-api",
|
|
59
|
+
"iat": int(time.time()),
|
|
60
|
+
"exp": int(time.time()) + ttl,
|
|
61
|
+
}, separators=(",", ":")).encode())
|
|
62
|
+
signing = f"{header}.{payload}".encode()
|
|
63
|
+
sig = b64(hmac.new(secret.encode(), signing, hashlib.sha256).digest())
|
|
64
|
+
return f"{header}.{payload}.{sig}"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use your own secret outside local Compose. Do not ship the development secret.
|
|
68
|
+
|
|
69
|
+
## Record spans
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
from tracelens import TraceLens
|
|
74
|
+
|
|
75
|
+
tl = TraceLens(
|
|
76
|
+
endpoint="http://localhost:8080",
|
|
77
|
+
api_key=dev_token(),
|
|
78
|
+
service="support-agent",
|
|
79
|
+
buffer_ms=100,
|
|
80
|
+
max_batch_size=100,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
span = tl.start_span(
|
|
85
|
+
trace_id="trace-001",
|
|
86
|
+
agent="support-agent",
|
|
87
|
+
task="draft-reply",
|
|
88
|
+
)
|
|
89
|
+
span.set_attribute("llm.model", "claude-3-5-sonnet")
|
|
90
|
+
span.set_attribute("llm.input_tokens", 4200)
|
|
91
|
+
span.set_attribute("llm.output_tokens", 890)
|
|
92
|
+
span.set_attribute("cost.usd", 0.12)
|
|
93
|
+
span.set_attribute("prompt", "Summarise the ticket")
|
|
94
|
+
ended = span.end(status="ok")
|
|
95
|
+
|
|
96
|
+
follow_up = tl.start_span(
|
|
97
|
+
trace_id="trace-001",
|
|
98
|
+
agent="support-agent",
|
|
99
|
+
task="send-reply",
|
|
100
|
+
parent_id=ended.span_id,
|
|
101
|
+
)
|
|
102
|
+
follow_up.end(status="error", error_message="smtp timeout")
|
|
103
|
+
await tl.flush()
|
|
104
|
+
|
|
105
|
+
asyncio.run(main())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`start_span` returns a `Span` immediately. `end(status="ok")` or `end(status="error", error_message=...)` freezes the span, sets `latency_ms` from the wall clock, and queues it. Calling `end()` twice raises `RuntimeError`.
|
|
109
|
+
|
|
110
|
+
`await flush()` sends every queued batch and closes the HTTP client. Call it before the process exits. Spans left in the buffer are not delivered.
|
|
111
|
+
|
|
112
|
+
`service` is accepted on the client and is not written onto the span. Identity in the stored trace comes from `trace_id`, `agent`, and `task`.
|
|
113
|
+
|
|
114
|
+
`buffer_ms` is how long a batch waits before a flush when an event loop is already running. `max_batch_size` flushes early once that many spans are queued. The collector rejects a batch larger than 1000 spans.
|
|
115
|
+
|
|
116
|
+
## Fields the collector indexes
|
|
117
|
+
|
|
118
|
+
`end()` copies four attributes onto dedicated columns. Other attributes are stored on the span as JSON.
|
|
119
|
+
|
|
120
|
+
| `set_attribute` key | Stored column | Default |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `llm.model` | `llm_model` | `null` |
|
|
123
|
+
| `llm.input_tokens` | `input_tokens` | `0` |
|
|
124
|
+
| `llm.output_tokens` | `output_tokens` | `0` |
|
|
125
|
+
| `cost.usd` | `cost_usd` | `0.0` |
|
|
126
|
+
|
|
127
|
+
`agent` is stored as `agent_type`. `task` is stored as `tool_name`. `status` must be `ok` or `error`.
|
|
128
|
+
|
|
129
|
+
Useful free-form attributes, if you want replay to show them, are `prompt`, `response`, `tool_output`, and `decision`. Replay reads those keys and ignores the rest.
|
|
130
|
+
|
|
131
|
+
## Parent spans
|
|
132
|
+
|
|
133
|
+
Pass the parent id yourself. The span id exists only after `end()`.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
parent = tl.start_span(trace_id="trace-001", agent="planner", task="plan")
|
|
137
|
+
parent_data = parent.end(status="ok")
|
|
138
|
+
child = tl.start_span(
|
|
139
|
+
trace_id="trace-001",
|
|
140
|
+
agent="planner",
|
|
141
|
+
task="search",
|
|
142
|
+
parent_id=parent_data.span_id,
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## LangChain callback
|
|
147
|
+
|
|
148
|
+
`TraceLensLangChainHandler` implements the callback method names LangChain uses for chains, chat models, and tools: `on_chain_start`, `on_chain_end`, `on_chain_error`, `on_llm_start`, `on_llm_end`, `on_llm_error`, `on_tool_start`, `on_tool_end`, `on_tool_error`. It is not a subclass of LangChain's `BaseCallbackHandler`. Pass it where a callback object with those methods is accepted.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from tracelens import TraceLens, TraceLensLangChainHandler
|
|
152
|
+
|
|
153
|
+
tl = TraceLens(endpoint="http://localhost:8080", api_key=dev_token(), service="support-agent")
|
|
154
|
+
handler = TraceLensLangChainHandler(tl, trace_id="trace-lc-001", agent_name="support-agent")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
On `on_llm_end`, token counts are read from `response.llm_output["token_usage"]` (`prompt_tokens` and `completion_tokens`). Cost is then estimated as `$3 / 1M` input tokens and `$15 / 1M` output tokens. That estimate is not provider pricing. Prompts, outputs, and tool payloads are truncated to 1000 characters.
|
|
158
|
+
|
|
159
|
+
One handler shares a single `trace_id`. Create a new handler per run if each run should be its own trace.
|
|
160
|
+
|
|
161
|
+
## LlamaIndex helper
|
|
162
|
+
|
|
163
|
+
`TraceLensLlamaIndexHandler` is a small helper you call yourself. It is not a LlamaIndex `CallbackHandler` and it does not register with a callback manager.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from tracelens import TraceLens, TraceLensLlamaIndexHandler
|
|
167
|
+
|
|
168
|
+
tl = TraceLens(endpoint="http://localhost:8080", api_key=dev_token(), service="rag")
|
|
169
|
+
rag = TraceLensLlamaIndexHandler(tl, trace_id="trace-rag-001")
|
|
170
|
+
|
|
171
|
+
rag.on_retrieve_start("quarterly revenue", event_id="ret-1")
|
|
172
|
+
rag.on_retrieve_end(nodes, event_id="ret-1")
|
|
173
|
+
|
|
174
|
+
rag.on_query_start("quarterly revenue", event_id="q-1")
|
|
175
|
+
rag.on_query_end(response, event_id="q-1")
|
|
176
|
+
await tl.flush()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`on_retrieve_end` records `retrieved_chunks_count`. `on_query_end` records `response`, truncated to 1000 characters.
|
|
180
|
+
|
|
181
|
+
## What the client does not do
|
|
182
|
+
|
|
183
|
+
- It does not create the collector, the database, or the dashboard.
|
|
184
|
+
- It does not retry a failed post. A non-201 response or a connection error is printed and the batch is dropped.
|
|
185
|
+
- It does not generate `trace_id`. You pass one per run.
|
|
186
|
+
- It does not read traces back. Search, replay, and metrics are collector routes. See the repository README.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
From `sdk/python`, with `pytest` installed:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python3 -m pip install -e .
|
|
194
|
+
python3 -m pytest
|
|
195
|
+
python3 -m build
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The package metadata lives in `pyproject.toml`. Tests live in `tests/` and are not part of the installed package.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT. Copyright (c) 2026 pisigmac.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_adapters.py
|
|
4
|
+
tests/test_tracer.py
|
|
5
|
+
tracelens/__init__.py
|
|
6
|
+
tracelens/batcher.py
|
|
7
|
+
tracelens/span.py
|
|
8
|
+
tracelens/tracer.py
|
|
9
|
+
tracelens/types.py
|
|
10
|
+
tracelens/adapters/__init__.py
|
|
11
|
+
tracelens/adapters/langchain.py
|
|
12
|
+
tracelens/adapters/llamaindex.py
|
|
13
|
+
tracelens_sdk.egg-info/PKG-INFO
|
|
14
|
+
tracelens_sdk.egg-info/SOURCES.txt
|
|
15
|
+
tracelens_sdk.egg-info/dependency_links.txt
|
|
16
|
+
tracelens_sdk.egg-info/requires.txt
|
|
17
|
+
tracelens_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tracelens
|