datagol-lens 0.2.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DataGOL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: datagol-lens
3
+ Version: 0.2.0
4
+ Summary: Python SDK for DataGOL Lens: tracing, cost and budgets for LLM applications
5
+ Author: DataGOL
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://datagol.ai
8
+ Keywords: llm,observability,tracing,langchain,datagol
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: System :: Monitoring
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: httpx>=0.27.0
16
+ Provides-Extra: langchain
17
+ Requires-Dist: langchain-core>=0.3; extra == "langchain"
18
+ Dynamic: license-file
19
+
20
+ # datagol-lens
21
+
22
+ Python SDK for [DataGOL Lens](https://datagol.ai): traces, token usage, cost and
23
+ budgets for LLM applications. Wrap your Anthropic or OpenAI client, or attach a
24
+ LangChain callback, and every call shows up in Lens as a trace with its spans.
25
+
26
+ ```bash
27
+ pip install datagol-lens
28
+ pip install "datagol-lens[langchain]" # for the LangChain / LangGraph handler
29
+ ```
30
+
31
+ ## Configure
32
+
33
+ | Variable | Description |
34
+ |---|---|
35
+ | `DATAGOL_LENS_API_URL` | Your Lens backend URL (default `http://localhost:8000`) |
36
+ | `DATAGOL_LENS_API_KEY` | A project API key (`dgl_...`), created in Lens for the project you trace into |
37
+
38
+ Both can also be passed to `DatagolLensClient(api_url=..., api_key=...)`.
39
+
40
+ ## Trace one call per request
41
+
42
+ ```python
43
+ import anthropic
44
+ from datagol_lens import DatagolLensClient
45
+
46
+ sdk = DatagolLensClient()
47
+ client = sdk.wrap(anthropic.Anthropic(), project_id="<project-id>", name="support-bot")
48
+
49
+ resp = client.messages.create(
50
+ model="claude-haiku-4-5-20251001",
51
+ messages=[{"role": "user", "content": "What is Python?"}],
52
+ max_tokens=200,
53
+ )
54
+ ```
55
+
56
+ The same works for `openai.OpenAI()`.
57
+
58
+ ## Trace a multi-step run
59
+
60
+ ```python
61
+ from datagol_lens import DatagolLensClient, TraceContext
62
+
63
+ sdk = DatagolLensClient()
64
+
65
+ with TraceContext(sdk, "<project-id>", name="research-agent",
66
+ session_id="sess-abc", user_id="user-123") as ctx:
67
+ llm = ctx.wrap(anthropic.Anthropic()) # each call becomes a span
68
+ resp = llm.messages.create(model="claude-haiku-4-5-20251001", max_tokens=300,
69
+ messages=[{"role": "user", "content": "Summarize recent AI news"}])
70
+ ctx.add_span(name="vector_search", span_type="retriever",
71
+ input={"query": "AI news"}, output={"chunks": 2})
72
+ ctx.set_output({"summary": resp.content[0].text})
73
+ # the trace is sent when the block exits
74
+ ```
75
+
76
+ ## LangChain and LangGraph
77
+
78
+ ```python
79
+ from datagol_lens import DatagolLensClient, TraceContext
80
+ from datagol_lens.langchain import DatagolLensCallbackHandler
81
+
82
+ with TraceContext(DatagolLensClient(), "<project-id>", name="langchain-agent") as ctx:
83
+ handler = DatagolLensCallbackHandler(ctx)
84
+ answer = chain.invoke("What is Python?", config={"callbacks": [handler]})
85
+ ```
86
+
87
+ Every chat-model call is recorded as an `llm` span with its token usage, and every
88
+ tool call as a `tool` span. Attach the same handler to a LangGraph agent's `config`.
89
+
90
+ ## Budgets and attribution
91
+
92
+ Pass `company_id`, `agent_key` and `user_id` to `TraceContext` and Lens attributes
93
+ the trace's cost to that company, agent and user. Before a run, check the budgets:
94
+
95
+ ```python
96
+ from datagol_lens import BudgetExceeded
97
+
98
+ result = sdk.check_budget("<project-id>", company_id="acme", user_id="u-1", agent_key="support-bot")
99
+ if not result["allowed"]:
100
+ raise BudgetExceeded("over budget", result)
101
+ ```
102
+
103
+ Lens prices each span from its model and token counts, so the SDK only reports usage.
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,88 @@
1
+ # datagol-lens
2
+
3
+ Python SDK for [DataGOL Lens](https://datagol.ai): traces, token usage, cost and
4
+ budgets for LLM applications. Wrap your Anthropic or OpenAI client, or attach a
5
+ LangChain callback, and every call shows up in Lens as a trace with its spans.
6
+
7
+ ```bash
8
+ pip install datagol-lens
9
+ pip install "datagol-lens[langchain]" # for the LangChain / LangGraph handler
10
+ ```
11
+
12
+ ## Configure
13
+
14
+ | Variable | Description |
15
+ |---|---|
16
+ | `DATAGOL_LENS_API_URL` | Your Lens backend URL (default `http://localhost:8000`) |
17
+ | `DATAGOL_LENS_API_KEY` | A project API key (`dgl_...`), created in Lens for the project you trace into |
18
+
19
+ Both can also be passed to `DatagolLensClient(api_url=..., api_key=...)`.
20
+
21
+ ## Trace one call per request
22
+
23
+ ```python
24
+ import anthropic
25
+ from datagol_lens import DatagolLensClient
26
+
27
+ sdk = DatagolLensClient()
28
+ client = sdk.wrap(anthropic.Anthropic(), project_id="<project-id>", name="support-bot")
29
+
30
+ resp = client.messages.create(
31
+ model="claude-haiku-4-5-20251001",
32
+ messages=[{"role": "user", "content": "What is Python?"}],
33
+ max_tokens=200,
34
+ )
35
+ ```
36
+
37
+ The same works for `openai.OpenAI()`.
38
+
39
+ ## Trace a multi-step run
40
+
41
+ ```python
42
+ from datagol_lens import DatagolLensClient, TraceContext
43
+
44
+ sdk = DatagolLensClient()
45
+
46
+ with TraceContext(sdk, "<project-id>", name="research-agent",
47
+ session_id="sess-abc", user_id="user-123") as ctx:
48
+ llm = ctx.wrap(anthropic.Anthropic()) # each call becomes a span
49
+ resp = llm.messages.create(model="claude-haiku-4-5-20251001", max_tokens=300,
50
+ messages=[{"role": "user", "content": "Summarize recent AI news"}])
51
+ ctx.add_span(name="vector_search", span_type="retriever",
52
+ input={"query": "AI news"}, output={"chunks": 2})
53
+ ctx.set_output({"summary": resp.content[0].text})
54
+ # the trace is sent when the block exits
55
+ ```
56
+
57
+ ## LangChain and LangGraph
58
+
59
+ ```python
60
+ from datagol_lens import DatagolLensClient, TraceContext
61
+ from datagol_lens.langchain import DatagolLensCallbackHandler
62
+
63
+ with TraceContext(DatagolLensClient(), "<project-id>", name="langchain-agent") as ctx:
64
+ handler = DatagolLensCallbackHandler(ctx)
65
+ answer = chain.invoke("What is Python?", config={"callbacks": [handler]})
66
+ ```
67
+
68
+ Every chat-model call is recorded as an `llm` span with its token usage, and every
69
+ tool call as a `tool` span. Attach the same handler to a LangGraph agent's `config`.
70
+
71
+ ## Budgets and attribution
72
+
73
+ Pass `company_id`, `agent_key` and `user_id` to `TraceContext` and Lens attributes
74
+ the trace's cost to that company, agent and user. Before a run, check the budgets:
75
+
76
+ ```python
77
+ from datagol_lens import BudgetExceeded
78
+
79
+ result = sdk.check_budget("<project-id>", company_id="acme", user_id="u-1", agent_key="support-bot")
80
+ if not result["allowed"]:
81
+ raise BudgetExceeded("over budget", result)
82
+ ```
83
+
84
+ Lens prices each span from its model and token counts, so the SDK only reports usage.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,17 @@
1
+ from datagol_lens.client import DatagolLensClient, LensRequestError, BudgetExceeded
2
+ from datagol_lens.gateway_client import GatewayClient
3
+ from datagol_lens.tracer import TraceContext, estimate_cost
4
+ from datagol_lens.decorators import trace, trace_llm_call
5
+ from datagol_lens.wrappers import TracedAnthropicClient, TracedOpenAIClient, TracedLiteLLMCompletion
6
+ from datagol_lens.redaction import Redactor, RedactionRule, built_in_rule
7
+ from datagol_lens.guardrails import Guardrails, GuardrailRule, GuardrailViolation, built_in_jailbreak_rule
8
+ from datagol_lens.langchain import DatagolLensCallbackHandler
9
+
10
+ __all__ = [
11
+ "DatagolLensClient", "LensRequestError", "BudgetExceeded", "GatewayClient", "TraceContext", "estimate_cost",
12
+ "trace", "trace_llm_call",
13
+ "TracedAnthropicClient", "TracedOpenAIClient", "TracedLiteLLMCompletion",
14
+ "Redactor", "RedactionRule", "built_in_rule",
15
+ "Guardrails", "GuardrailRule", "GuardrailViolation", "built_in_jailbreak_rule",
16
+ "DatagolLensCallbackHandler",
17
+ ]
@@ -0,0 +1,285 @@
1
+ import atexit
2
+ import json
3
+ import os
4
+ import queue
5
+ import threading
6
+ import time
7
+ import httpx
8
+ from typing import Any, Dict, List, Optional, Union
9
+ from datagol_lens.redaction import Redactor
10
+ from datagol_lens.guardrails import Guardrails
11
+
12
+ # Provider -> env var that Anthropic/OpenAI/litellm SDKs read their API key from by default.
13
+ # Finished traces waiting for the background sender; beyond this they are dropped
14
+ # rather than holding memory while Lens is unreachable.
15
+ _MAX_QUEUE = 1000
16
+
17
+ _PROVIDER_ENV_VARS = {
18
+ "anthropic": "ANTHROPIC_API_KEY",
19
+ "openai": "OPENAI_API_KEY",
20
+ }
21
+
22
+
23
+ class LensRequestError(Exception):
24
+ """A Lens API call failed: network error, timeout, non-2xx status, or bad response body.
25
+
26
+ ``status_code`` is set when the server answered with a non-2xx status.
27
+ """
28
+
29
+ def __init__(self, message: str, status_code: Optional[int] = None):
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+
33
+
34
+ class BudgetExceeded(Exception):
35
+ """Raised by callers when a budget check blocks a request.
36
+
37
+ ``result`` is the full ``check_budget`` response and ``budget`` its ``blocked_by``
38
+ BudgetStatus (or None, e.g. when the check itself failed and the caller failed closed).
39
+ """
40
+
41
+ def __init__(self, message: str, result: Optional[Dict[str, Any]] = None):
42
+ super().__init__(message)
43
+ self.result = result
44
+ self.budget = (result or {}).get("blocked_by")
45
+
46
+
47
+ class DatagolLensClient:
48
+ """HTTP client for the Datagol Lens API."""
49
+
50
+ def __init__(
51
+ self,
52
+ api_url: Optional[str] = None,
53
+ api_key: Optional[str] = None,
54
+ virtual_key: Optional[str] = None,
55
+ auto_configure_provider_env: bool = True,
56
+ redactor: Optional[Redactor] = None,
57
+ guardrails: Optional[Guardrails] = None,
58
+ background: bool = True,
59
+ ):
60
+ """``background`` (default) sends finished traces from a daemon thread so the
61
+ traced code never waits on Lens; call :meth:`flush` before a short-lived
62
+ process exits (an exit hook also tries). Pass False to send inline, for
63
+ example in a serverless function that is frozen right after returning."""
64
+ self.background = background
65
+ self._queue: "queue.Queue[tuple[str, bytes]]" = queue.Queue(maxsize=_MAX_QUEUE)
66
+ self._sender: Optional[threading.Thread] = None
67
+ self._sender_lock = threading.Lock()
68
+ self.dropped_traces = 0
69
+ self.failed_traces = 0
70
+ self.api_url = (api_url or os.getenv("DATAGOL_LENS_API_URL", "http://localhost:8000")).rstrip("/")
71
+ self.api_key = api_key or os.getenv("DATAGOL_LENS_API_KEY", "")
72
+ self._client = httpx.Client(
73
+ base_url=self.api_url,
74
+ headers={"Authorization": f"Bearer {self.api_key}"} if self.api_key else {},
75
+ timeout=10.0,
76
+ )
77
+
78
+ # Set so every TraceContext created from this client — including via wrap()/@trace,
79
+ # which don't take a redactor/virtual_key_id argument themselves — picks it up automatically.
80
+ self.redactor = redactor
81
+ self.guardrails = guardrails
82
+ self.virtual_key_id: Optional[str] = None
83
+ self.resolved_provider: Optional[str] = None
84
+ self.resolved_allowed_models: Optional[List[str]] = None
85
+ self._prices: Dict[str, Optional[Dict[str, Any]]] = {}
86
+
87
+ virtual_key = virtual_key or os.getenv("DATAGOL_LENS_VIRTUAL_KEY", "")
88
+ if virtual_key:
89
+ self.activate_virtual_key(virtual_key, auto_configure_env=auto_configure_provider_env)
90
+
91
+ def post_trace(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
92
+ resp = self._client.post(f"/api/v1/projects/{project_id}/traces", json=payload)
93
+ resp.raise_for_status()
94
+ return resp.json()
95
+
96
+ def enqueue_trace(self, project_id: str, payload: dict[str, Any]) -> None:
97
+ """Send a finished trace without blocking the caller (or inline when
98
+ ``background=False``). Never raises. A full queue drops the trace and
99
+ counts it in ``dropped_traces``."""
100
+ # Serialised here, in the caller's thread, so later changes to the caller's
101
+ # dicts can't alter what gets sent.
102
+ try:
103
+ body = json.dumps(payload).encode()
104
+ except (TypeError, ValueError):
105
+ self.failed_traces += 1
106
+ return
107
+ if not self.background:
108
+ self._send(project_id, body)
109
+ return
110
+ self._ensure_sender()
111
+ try:
112
+ self._queue.put_nowait((project_id, body))
113
+ except queue.Full:
114
+ self.dropped_traces += 1
115
+
116
+ def flush(self, timeout: float = 5.0) -> bool:
117
+ """Wait for queued traces to be sent. True if the queue drained in time."""
118
+ deadline = time.monotonic() + timeout
119
+ while self._queue.unfinished_tasks:
120
+ if time.monotonic() > deadline:
121
+ return False
122
+ time.sleep(0.01)
123
+ return True
124
+
125
+ def _send(self, project_id: str, body: bytes) -> None:
126
+ # The trace carries its own id, so a retry after a transport failure can't
127
+ # double-write; one short retry covers a dropped connection or a 5xx blip.
128
+ for attempt in range(2):
129
+ try:
130
+ resp = self._client.post(f"/api/v1/projects/{project_id}/traces", content=body,
131
+ headers={"Content-Type": "application/json"})
132
+ resp.raise_for_status()
133
+ return
134
+ except Exception:
135
+ if attempt == 0:
136
+ time.sleep(0.2)
137
+ self.failed_traces += 1
138
+
139
+ def _ensure_sender(self) -> None:
140
+ if self._sender is not None and self._sender.is_alive():
141
+ return
142
+ with self._sender_lock:
143
+ if self._sender is None or not self._sender.is_alive():
144
+ if self._sender is None:
145
+ atexit.register(self.flush)
146
+ self._sender = threading.Thread(target=self._drain, name="datagol-lens-sender", daemon=True)
147
+ self._sender.start()
148
+
149
+ def _drain(self) -> None:
150
+ while True:
151
+ project_id, body = self._queue.get()
152
+ try:
153
+ self._send(project_id, body)
154
+ finally:
155
+ self._queue.task_done()
156
+
157
+ def resolve_price(self, model: str) -> Optional[Dict[str, Any]]:
158
+ """Per-token USD rates for ``model`` from the server's price table
159
+ (``GET /api/v1/pricing/{model}``), looked up once per model per client.
160
+ None when the server is unreachable or doesn't know the model, in which
161
+ case the tracer falls back to its builtin table."""
162
+ if model in self._prices:
163
+ return self._prices[model]
164
+ price = None
165
+ try:
166
+ resp = self._client.get(f"/api/v1/pricing/{model}")
167
+ if resp.status_code == 200:
168
+ price = resp.json()
169
+ except Exception:
170
+ price = None
171
+ self._prices[model] = price
172
+ return price
173
+
174
+ def post_feedback(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
175
+ resp = self._client.post(f"/api/v1/projects/{project_id}/feedback", json=payload)
176
+ resp.raise_for_status()
177
+ return resp.json()
178
+
179
+ def _request_json(self, method: str, path: str, timeout: float, **kwargs) -> Dict[str, Any]:
180
+ """Send a request and return its JSON body, raising :class:`LensRequestError` on any
181
+ network error, timeout, non-2xx status, or non-JSON body."""
182
+ try:
183
+ resp = self._client.request(method, path, timeout=timeout, **kwargs)
184
+ resp.raise_for_status()
185
+ return resp.json()
186
+ except httpx.HTTPStatusError as exc:
187
+ raise LensRequestError(
188
+ f"{method} {path} returned {exc.response.status_code}", exc.response.status_code
189
+ ) from exc
190
+ except (httpx.HTTPError, ValueError) as exc:
191
+ raise LensRequestError(f"{method} {path} failed: {exc}") from exc
192
+
193
+ def check_budget(self, project_id: str, company_id: str, user_id: Optional[str] = None,
194
+ agent_key: Optional[str] = None, timeout: float = 2.0) -> Dict[str, Any]:
195
+ """Check whether a company/agent/user is within its Lens budgets.
196
+
197
+ Returns ``{"allowed", "decision", "blocked_by", "warnings"}``. Raises
198
+ :class:`LensRequestError` on network errors, timeouts, and non-2xx responses so the
199
+ caller can fail closed.
200
+ """
201
+ return self._request_json(
202
+ "POST", f"/api/v1/projects/{project_id}/budget/check", timeout,
203
+ json={"company_id": company_id, "user_id": user_id, "agent_key": agent_key},
204
+ )
205
+
206
+ def get_enforced_companies(self, project_id: str, timeout: float = 2.0) -> Dict[str, Any]:
207
+ """Return ``{"company_ids": [...], "version": str}`` — the DataGOL company ids with
208
+ budget enforcement on. Raises :class:`LensRequestError` on failure."""
209
+ return self._request_json(
210
+ "GET", f"/api/v1/projects/{project_id}/budget/enforced-companies", timeout,
211
+ )
212
+
213
+ def resolve_virtual_key(self, token: str, model: Optional[str] = None) -> dict[str, Any]:
214
+ """Resolve a virtual key ("vk_...") to its real provider key + metadata.
215
+
216
+ The real key is returned once per call and never logged by the SDK — callers
217
+ should generally use :meth:`activate_virtual_key` instead of calling this directly.
218
+ """
219
+ resp = self._client.post("/api/v1/virtual-keys/resolve", json={"token": token, "model": model})
220
+ resp.raise_for_status()
221
+ return resp.json()
222
+
223
+ def activate_virtual_key(self, token: str, auto_configure_env: bool = True) -> None:
224
+ """Resolve a virtual key and (by default) inject the real provider key into the
225
+ environment variable that provider's own SDK reads, so ``anthropic.Anthropic()``,
226
+ ``openai.OpenAI()``, or ``litellm.completion(...)`` work without ever touching
227
+ the real secret in application code.
228
+ """
229
+ result = self.resolve_virtual_key(token)
230
+ self.virtual_key_id = result["id"]
231
+ self.resolved_provider = result["provider"]
232
+ self.resolved_allowed_models = result.get("allowed_models")
233
+ if auto_configure_env:
234
+ env_var = _PROVIDER_ENV_VARS.get(self.resolved_provider)
235
+ if env_var:
236
+ os.environ[env_var] = result["api_key"]
237
+
238
+ def gateway(self, virtual_key: Optional[str] = None) -> "GatewayClient":
239
+ """Return a :class:`~datagol_lens.gateway_client.GatewayClient` (the
240
+ LiteLLM/OpenAI-compatible ``/v1/...`` gateway) sharing this client's ``api_url``.
241
+ """
242
+ from datagol_lens.gateway_client import GatewayClient
243
+ return GatewayClient(api_url=self.api_url, virtual_key=virtual_key)
244
+
245
+ def get_redaction_rules(self, project_id: str) -> List[Dict[str, Any]]:
246
+ """Fetch active PII/sensitive-data redaction rules configured for a project."""
247
+ resp = self._client.get(f"/api/v1/projects/{project_id}/redaction-rules")
248
+ resp.raise_for_status()
249
+ return resp.json()
250
+
251
+ def get_guardrails(self, project_id: str) -> List[Dict[str, Any]]:
252
+ """Fetch active prompt guardrail rules configured for a project."""
253
+ resp = self._client.get(f"/api/v1/projects/{project_id}/guardrails")
254
+ resp.raise_for_status()
255
+ return resp.json()
256
+
257
+ def wrap(self, llm_client, project_id: str,
258
+ name: Optional[str] = None,
259
+ tags: Optional[Union[List[str], Dict[str, str]]] = None,
260
+ metadata: Optional[Dict[str, Any]] = None,
261
+ guardrails: Optional[Guardrails] = None):
262
+ """Wrap an LLM client so each call automatically creates a trace.
263
+
264
+ If guardrails are configured (via ``guardrails=`` here or on the client), every
265
+ prompt is validated against them before it reaches the LLM; a violation of a
266
+ ``block``-action rule raises :class:`GuardrailViolation` and the call never happens.
267
+
268
+ Usage::
269
+
270
+ client = sdk.wrap(anthropic.Anthropic(), project_id="proj-123")
271
+ client.messages.create(model="claude-haiku-4-5-20251001", messages=[...], max_tokens=100)
272
+ """
273
+ from datagol_lens.wrappers import wrap_for_trace
274
+ return wrap_for_trace(llm_client, self, project_id, name=name, tags=tags, metadata=metadata,
275
+ guardrails=guardrails)
276
+
277
+ def close(self):
278
+ self.flush()
279
+ self._client.close()
280
+
281
+ def __enter__(self):
282
+ return self
283
+
284
+ def __exit__(self, *_):
285
+ self.close()
@@ -0,0 +1,62 @@
1
+ import functools
2
+ import time
3
+ from typing import Any, Callable, Dict, List, Optional, Union
4
+ from datagol_lens.client import DatagolLensClient
5
+ from datagol_lens.tracer import TraceContext, estimate_cost
6
+
7
+
8
+ def trace(
9
+ project_id: str,
10
+ name: Optional[str] = None,
11
+ tags: Optional[Union[List[str], Dict[str, str]]] = None,
12
+ metadata: Optional[Dict[str, Any]] = None,
13
+ client: Optional[DatagolLensClient] = None,
14
+ ):
15
+ """Decorator: wraps a function in a TraceContext and ships the trace on completion.
16
+
17
+ Usage::
18
+
19
+ @trace(project_id="proj-123", tags=["chat"])
20
+ def answer_question(question: str) -> str:
21
+ ...
22
+ """
23
+ def decorator(fn: Callable) -> Callable:
24
+ @functools.wraps(fn)
25
+ def wrapper(*args, **kwargs):
26
+ _client = client or DatagolLensClient()
27
+ trace_name = name or fn.__qualname__
28
+ ctx = TraceContext(
29
+ client=_client,
30
+ project_id=project_id,
31
+ name=trace_name,
32
+ input={"args": list(args), "kwargs": kwargs},
33
+ tags=tags or [],
34
+ metadata=metadata or {},
35
+ )
36
+ with ctx:
37
+ result = fn(*args, **kwargs)
38
+ ctx.set_output({"result": result})
39
+ return result
40
+ return wrapper
41
+ return decorator
42
+
43
+
44
+ def trace_llm_call(
45
+ ctx: TraceContext,
46
+ model: str,
47
+ messages: list[dict],
48
+ response_text: str,
49
+ prompt_tokens: int,
50
+ completion_tokens: int,
51
+ span_name: str = "llm_call",
52
+ ):
53
+ """Helper to record a single LLM call as a span inside an existing TraceContext."""
54
+ ctx.add_span(
55
+ name=span_name,
56
+ span_type="llm",
57
+ input={"messages": messages},
58
+ output={"content": response_text},
59
+ model=model,
60
+ prompt_tokens=prompt_tokens,
61
+ completion_tokens=completion_tokens,
62
+ )