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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ from .langchain import TraceLensLangChainHandler
2
+ from .llamaindex import TraceLensLlamaIndexHandler
3
+
4
+ __all__ = [
5
+ "TraceLensLangChainHandler",
6
+ "TraceLensLlamaIndexHandler",
7
+ ]
@@ -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
+ httpx>=0.27.0
@@ -0,0 +1 @@
1
+ tracelens