sledtrace 0.7.0__py3-none-any.whl

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.
raglens/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ import warnings
2
+
3
+ from .trace import trace, RAGLensTrace, SledTraceTrace
4
+ from .chunks import ChunkNormalizationError, normalize_chunk, normalize_chunks
5
+
6
+ __version__ = "0.7.0"
7
+
8
+ warnings.warn(
9
+ "The 'raglens' package is deprecated and kept only for temporary compatibility. "
10
+ "Please import from 'sledtrace' instead.",
11
+ DeprecationWarning,
12
+ stacklevel=2,
13
+ )
14
+
15
+ __all__ = [
16
+ "__version__",
17
+ "trace",
18
+ "SledTraceTrace",
19
+ "RAGLensTrace",
20
+ "ChunkNormalizationError",
21
+ "normalize_chunk",
22
+ "normalize_chunks",
23
+ ]
raglens/chunks.py ADDED
@@ -0,0 +1,499 @@
1
+ """
2
+ Chunk normalization helpers for SledTrace.
3
+
4
+ RAG frameworks return retrieved chunks in many shapes:
5
+
6
+ - Standard dict:
7
+ {"text": "...", "source": "...", "score": 0.9}
8
+
9
+ - LangChain-like:
10
+ {"page_content": "...", "metadata": {"source": "..."}}
11
+
12
+ - Haystack-like:
13
+ {"content": "...", "meta": {"source": "..."}}
14
+
15
+ - LlamaIndex-like:
16
+ {"node": {"text": "...", "metadata": {"file_name": "..."}}, "score": 0.8}
17
+
18
+ - Tuple result:
19
+ (document, score)
20
+
21
+ - Bare string:
22
+ "chunk text..."
23
+
24
+ SledTrace only needs a normalized chunk payload:
25
+
26
+ {
27
+ "id": "...",
28
+ "text": "...",
29
+ "source": "...",
30
+ "score": 0.9,
31
+ "rank": 1,
32
+ "metadata": {...}
33
+ }
34
+
35
+ Minimum diagnostic contract:
36
+ - text is required
37
+ - source is recommended, falls back to "unknown"
38
+ - score is optional
39
+ - rank is optional but auto-filled by normalize_chunks
40
+ - metadata is optional
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import hashlib
46
+ from collections.abc import Mapping
47
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Union
48
+
49
+
50
+ TextExtractor = Union[str, Callable[[Any], Optional[str]]]
51
+ ValueExtractor = Union[str, Callable[[Any], Any]]
52
+
53
+
54
+ class ChunkNormalizationError(ValueError):
55
+ """Raised when a retrieved item cannot be normalized into a SledTrace chunk."""
56
+
57
+
58
+ def normalize_chunk(
59
+ raw: Any,
60
+ rank: Optional[int] = None,
61
+ *,
62
+ text: Optional[TextExtractor] = None,
63
+ source: Optional[ValueExtractor] = None,
64
+ score: Optional[ValueExtractor] = None,
65
+ chunk_id: Optional[ValueExtractor] = None,
66
+ metadata: Optional[ValueExtractor] = None,
67
+ default_source: str = "unknown",
68
+ ) -> Dict[str, Any]:
69
+ """
70
+ Normalize one raw retrieved item into the SledTrace chunk contract.
71
+
72
+ Parameters:
73
+ raw:
74
+ A raw retrieved item. Can be a dict, object, tuple, string, etc.
75
+
76
+ rank:
77
+ Optional 1-based rank. If omitted, rank is left as None.
78
+
79
+ text/source/score/chunk_id/metadata:
80
+ Optional explicit extractors. Each can be:
81
+ - a dotted path string, such as "metadata.source" or "node.text"
82
+ - a callable, such as lambda d: d.page_content
83
+
84
+ default_source:
85
+ Used when no source-like field can be found.
86
+
87
+ Returns:
88
+ A dict with:
89
+ - id
90
+ - text
91
+ - source
92
+ - score
93
+ - rank
94
+ - metadata
95
+ """
96
+ item, tuple_score = _unwrap_tuple_result(raw)
97
+
98
+ extracted_text = _extract_text(item, explicit=text)
99
+ if not extracted_text:
100
+ raise ChunkNormalizationError(
101
+ "Could not normalize retrieved chunk because no text/content field was found. "
102
+ "Provide text='...' or pass an object with text/page_content/content/node.text."
103
+ )
104
+
105
+ extracted_metadata = _extract_metadata(item, explicit=metadata)
106
+ extracted_source = _extract_source(item, extracted_metadata, explicit=source)
107
+ extracted_score = _extract_score(item, tuple_score=tuple_score, explicit=score)
108
+ extracted_id = _extract_id(item, explicit=chunk_id)
109
+
110
+ if not extracted_id:
111
+ extracted_id = _stable_chunk_id(extracted_text, extracted_source or default_source)
112
+
113
+ normalized_metadata = dict(extracted_metadata)
114
+ normalized_metadata.setdefault("normalized_by", "sledtrace.normalize_chunk")
115
+
116
+ return {
117
+ "id": extracted_id,
118
+ "text": extracted_text,
119
+ "source": extracted_source or default_source,
120
+ "score": extracted_score,
121
+ "rank": rank,
122
+ "metadata": normalized_metadata,
123
+ }
124
+
125
+
126
+ def normalize_chunks(
127
+ raw_chunks: Iterable[Any],
128
+ *,
129
+ text: Optional[TextExtractor] = None,
130
+ source: Optional[ValueExtractor] = None,
131
+ score: Optional[ValueExtractor] = None,
132
+ chunk_id: Optional[ValueExtractor] = None,
133
+ metadata: Optional[ValueExtractor] = None,
134
+ default_source: str = "unknown",
135
+ start_rank: int = 1,
136
+ skip_invalid: bool = False,
137
+ ) -> List[Dict[str, Any]]:
138
+ """
139
+ Normalize many raw retrieved items into SledTrace chunks.
140
+
141
+ By default, invalid items raise ChunkNormalizationError.
142
+ Set skip_invalid=True to drop invalid items.
143
+ """
144
+ normalized: List[Dict[str, Any]] = []
145
+
146
+ for index, raw in enumerate(raw_chunks):
147
+ rank = start_rank + index
148
+
149
+ try:
150
+ normalized.append(
151
+ normalize_chunk(
152
+ raw,
153
+ rank=rank,
154
+ text=text,
155
+ source=source,
156
+ score=score,
157
+ chunk_id=chunk_id,
158
+ metadata=metadata,
159
+ default_source=default_source,
160
+ )
161
+ )
162
+ except ChunkNormalizationError:
163
+ if not skip_invalid:
164
+ raise
165
+
166
+ return normalized
167
+
168
+
169
+ def _unwrap_tuple_result(raw: Any) -> tuple[Any, Any]:
170
+ """
171
+ Common vector-store pattern:
172
+ (document, score)
173
+
174
+ Also supports:
175
+ [document, score]
176
+ """
177
+ if isinstance(raw, tuple) and len(raw) == 2:
178
+ return raw[0], raw[1]
179
+
180
+ if isinstance(raw, list) and len(raw) == 2 and not _looks_like_chunk_list(raw):
181
+ return raw[0], raw[1]
182
+
183
+ return raw, None
184
+
185
+
186
+ def _looks_like_chunk_list(value: list[Any]) -> bool:
187
+ if not value:
188
+ return False
189
+
190
+ return all(isinstance(item, (str, Mapping)) for item in value)
191
+
192
+
193
+ def _extract_text(item: Any, explicit: Optional[TextExtractor]) -> str:
194
+ if isinstance(item, str):
195
+ return item.strip()
196
+
197
+ if explicit is not None:
198
+ value = _extract_with_spec(item, explicit)
199
+ return _to_clean_string(value)
200
+
201
+ candidates = [
202
+ "text",
203
+ "page_content",
204
+ "content",
205
+ "body",
206
+ "chunk",
207
+ "node.text",
208
+ "node.content",
209
+ "node.page_content",
210
+ "node.text_resource.text",
211
+ "document.text",
212
+ "document.page_content",
213
+ "document.content",
214
+ ]
215
+
216
+ for path in candidates:
217
+ value = _get_path(item, path)
218
+ cleaned = _to_clean_string(value)
219
+
220
+ if cleaned:
221
+ return cleaned
222
+
223
+ # Object fallback for LangChain-style Document.
224
+ for attr in ("page_content", "text", "content"):
225
+ value = getattr(item, attr, None)
226
+ cleaned = _to_clean_string(value)
227
+
228
+ if cleaned:
229
+ return cleaned
230
+
231
+ # LlamaIndex-style node sometimes exposes get_content().
232
+ get_content = getattr(item, "get_content", None)
233
+ if callable(get_content):
234
+ cleaned = _to_clean_string(get_content())
235
+ if cleaned:
236
+ return cleaned
237
+
238
+ node = getattr(item, "node", None)
239
+ if node is not None:
240
+ get_content = getattr(node, "get_content", None)
241
+ if callable(get_content):
242
+ cleaned = _to_clean_string(get_content())
243
+ if cleaned:
244
+ return cleaned
245
+
246
+ return ""
247
+
248
+
249
+ def _extract_metadata(
250
+ item: Any,
251
+ explicit: Optional[ValueExtractor],
252
+ ) -> Dict[str, Any]:
253
+ if explicit is not None:
254
+ value = _extract_with_spec(item, explicit)
255
+ return _to_metadata_dict(value)
256
+
257
+ candidates = [
258
+ "metadata",
259
+ "meta",
260
+ "extra_info",
261
+ "node.metadata",
262
+ "node.meta",
263
+ "document.metadata",
264
+ "document.meta",
265
+ ]
266
+
267
+ merged: Dict[str, Any] = {}
268
+
269
+ for path in candidates:
270
+ value = _get_path(item, path)
271
+ if isinstance(value, Mapping):
272
+ merged.update(dict(value))
273
+
274
+ # Object fallback.
275
+ for attr in ("metadata", "meta", "extra_info"):
276
+ value = getattr(item, attr, None)
277
+ if isinstance(value, Mapping):
278
+ merged.update(dict(value))
279
+
280
+ node = getattr(item, "node", None)
281
+ if node is not None:
282
+ for attr in ("metadata", "meta", "extra_info"):
283
+ value = getattr(node, attr, None)
284
+ if isinstance(value, Mapping):
285
+ merged.update(dict(value))
286
+
287
+ return merged
288
+
289
+
290
+ def _extract_source(
291
+ item: Any,
292
+ metadata: Mapping[str, Any],
293
+ explicit: Optional[ValueExtractor],
294
+ ) -> str:
295
+ if explicit is not None:
296
+ return _to_clean_string(_extract_with_spec(item, explicit))
297
+
298
+ direct_candidates = [
299
+ "source",
300
+ "file_name",
301
+ "filename",
302
+ "doc_id",
303
+ "document_id",
304
+ "uri",
305
+ "url",
306
+ "path",
307
+ "title",
308
+ "name",
309
+ "node.source",
310
+ "node.file_name",
311
+ "node.id_",
312
+ "document.source",
313
+ "document.file_name",
314
+ "document.id",
315
+ ]
316
+
317
+ metadata_candidates = [
318
+ "source",
319
+ "file_name",
320
+ "filename",
321
+ "doc_id",
322
+ "document_id",
323
+ "file_id",
324
+ "uri",
325
+ "url",
326
+ "path",
327
+ "title",
328
+ "name",
329
+ ]
330
+
331
+ for path in direct_candidates:
332
+ value = _to_clean_string(_get_path(item, path))
333
+ if value:
334
+ return value
335
+
336
+ for key in metadata_candidates:
337
+ value = _to_clean_string(metadata.get(key))
338
+ if value:
339
+ return value
340
+
341
+ return ""
342
+
343
+
344
+ def _extract_score(
345
+ item: Any,
346
+ *,
347
+ tuple_score: Any,
348
+ explicit: Optional[ValueExtractor],
349
+ ) -> Optional[float]:
350
+ if explicit is not None:
351
+ return _to_float_or_none(_extract_with_spec(item, explicit))
352
+
353
+ tuple_score_float = _to_float_or_none(tuple_score)
354
+ if tuple_score_float is not None:
355
+ return tuple_score_float
356
+
357
+ candidates = [
358
+ "score",
359
+ "similarity",
360
+ "similarity_score",
361
+ "rerank_score",
362
+ "relevance_score",
363
+ "distance",
364
+ "metadata.score",
365
+ "metadata.similarity",
366
+ "metadata.similarity_score",
367
+ "metadata.rerank_score",
368
+ "meta.score",
369
+ "meta.similarity",
370
+ "node.score",
371
+ ]
372
+
373
+ for path in candidates:
374
+ value = _to_float_or_none(_get_path(item, path))
375
+ if value is not None:
376
+ return value
377
+
378
+ for attr in (
379
+ "score",
380
+ "similarity",
381
+ "similarity_score",
382
+ "rerank_score",
383
+ "relevance_score",
384
+ "distance",
385
+ ):
386
+ value = _to_float_or_none(getattr(item, attr, None))
387
+ if value is not None:
388
+ return value
389
+
390
+ return None
391
+
392
+
393
+ def _extract_id(
394
+ item: Any,
395
+ explicit: Optional[ValueExtractor],
396
+ ) -> str:
397
+ if explicit is not None:
398
+ return _to_clean_string(_extract_with_spec(item, explicit))
399
+
400
+ candidates = [
401
+ "id",
402
+ "chunk_id",
403
+ "doc_id",
404
+ "document_id",
405
+ "node_id",
406
+ "id_",
407
+ "node.id",
408
+ "node.id_",
409
+ "node.node_id",
410
+ "document.id",
411
+ "metadata.id",
412
+ "metadata.chunk_id",
413
+ "metadata.doc_id",
414
+ "metadata.document_id",
415
+ "meta.id",
416
+ "meta.chunk_id",
417
+ ]
418
+
419
+ for path in candidates:
420
+ value = _to_clean_string(_get_path(item, path))
421
+ if value:
422
+ return value
423
+
424
+ for attr in ("id", "id_", "chunk_id", "doc_id", "document_id", "node_id"):
425
+ value = _to_clean_string(getattr(item, attr, None))
426
+ if value:
427
+ return value
428
+
429
+ return ""
430
+
431
+
432
+ def _extract_with_spec(item: Any, spec: Union[str, Callable[[Any], Any]]) -> Any:
433
+ if callable(spec):
434
+ return spec(item)
435
+
436
+ return _get_path(item, spec)
437
+
438
+
439
+ def _get_path(item: Any, path: str) -> Any:
440
+ current = item
441
+
442
+ for part in path.split("."):
443
+ if current is None:
444
+ return None
445
+
446
+ if isinstance(current, Mapping):
447
+ current = current.get(part)
448
+ continue
449
+
450
+ current = getattr(current, part, None)
451
+
452
+ return current
453
+
454
+
455
+ def _to_clean_string(value: Any) -> str:
456
+ if value is None:
457
+ return ""
458
+
459
+ if isinstance(value, str):
460
+ return value.strip()
461
+
462
+ if isinstance(value, (int, float)):
463
+ return str(value)
464
+
465
+ return ""
466
+
467
+
468
+ def _to_float_or_none(value: Any) -> Optional[float]:
469
+ if value is None:
470
+ return None
471
+
472
+ if isinstance(value, bool):
473
+ return None
474
+
475
+ if isinstance(value, (int, float)):
476
+ return float(value)
477
+
478
+ if isinstance(value, str) and value.strip():
479
+ try:
480
+ return float(value.strip())
481
+ except ValueError:
482
+ return None
483
+
484
+ return None
485
+
486
+
487
+ def _to_metadata_dict(value: Any) -> Dict[str, Any]:
488
+ if value is None:
489
+ return {}
490
+
491
+ if isinstance(value, Mapping):
492
+ return dict(value)
493
+
494
+ return {"raw_metadata": value}
495
+
496
+
497
+ def _stable_chunk_id(text: str, source: str) -> str:
498
+ digest = hashlib.sha1(f"{source}\n{text}".encode("utf-8")).hexdigest()[:12]
499
+ return f"chunk_{digest}"
raglens/client.py ADDED
File without changes
raglens/models.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, asdict
4
+ from typing import Any, Dict, List, Optional
5
+ from datetime import datetime, timezone
6
+ import time
7
+ import uuid
8
+
9
+
10
+ JsonDict = Dict[str, Any]
11
+
12
+
13
+ def utc_now_iso() -> str:
14
+ """Return current UTC time in ISO-8601 format."""
15
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
16
+
17
+
18
+ def now_ms() -> float:
19
+ """Return monotonic time in milliseconds."""
20
+ return time.perf_counter() * 1000
21
+
22
+
23
+ def new_id(prefix: str) -> str:
24
+ """Generate a simple prefixed UUID ID."""
25
+ return f"{prefix}_{uuid.uuid4().hex}"
26
+
27
+
28
+ @dataclass
29
+ class Span:
30
+ span_id: str
31
+ trace_id: str
32
+ parent_span_id: Optional[str]
33
+ type: str
34
+ name: str
35
+ status: str
36
+ input: JsonDict = field(default_factory=dict)
37
+ output: JsonDict = field(default_factory=dict)
38
+ metadata: JsonDict = field(default_factory=dict)
39
+ started_at: str = field(default_factory=utc_now_iso)
40
+ ended_at: Optional[str] = None
41
+ duration_ms: Optional[int] = None
42
+ error: Optional[JsonDict] = None
43
+
44
+ def to_dict(self) -> JsonDict:
45
+ return asdict(self)
46
+
47
+
48
+ @dataclass
49
+ class TraceRecord:
50
+ trace_id: str
51
+ name: str
52
+ status: str
53
+ input: JsonDict = field(default_factory=dict)
54
+ output: JsonDict = field(default_factory=dict)
55
+ metadata: JsonDict = field(default_factory=dict)
56
+ started_at: str = field(default_factory=utc_now_iso)
57
+ ended_at: Optional[str] = None
58
+ duration_ms: Optional[int] = None
59
+
60
+ def to_dict(self) -> JsonDict:
61
+ return asdict(self)
62
+
63
+
64
+ @dataclass
65
+ class TracePayload:
66
+ trace: TraceRecord
67
+ spans: List[Span] = field(default_factory=list)
68
+
69
+ def to_dict(self) -> JsonDict:
70
+ return {
71
+ "trace": self.trace.to_dict(),
72
+ "spans": [span.to_dict() for span in self.spans],
73
+ }
raglens/trace.py ADDED
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ import json
5
+ import os
6
+ import sys
7
+ import traceback
8
+ import urllib.request
9
+ import urllib.error
10
+
11
+ from .models import (
12
+ JsonDict,
13
+ Span,
14
+ TraceRecord,
15
+ TracePayload,
16
+ new_id,
17
+ now_ms,
18
+ utc_now_iso,
19
+ )
20
+
21
+
22
+ class RAGLensTrace:
23
+ """
24
+ A lightweight trace context manager for recording one RAG request.
25
+
26
+ Public API example:
27
+
28
+ from sledtrace import trace
29
+
30
+ with trace("refund-policy-qa") as t:
31
+ t.retrieval(query="...", chunks=[...])
32
+ t.llm(model="...", prompt="...", response="...")
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ name: str,
38
+ query: Optional[str] = None,
39
+ metadata: Optional[JsonDict] = None,
40
+ collector_url: Optional[str] = None,
41
+ ) -> None:
42
+ self.trace_id = new_id("trace")
43
+ self.name = name
44
+ self.query = query
45
+ self.metadata = metadata or {}
46
+ self.collector_url = resolve_collector_url(collector_url)
47
+
48
+ self._started_at: Optional[str] = None
49
+ self._ended_at: Optional[str] = None
50
+ self._start_ms: Optional[float] = None
51
+ self._duration_ms: Optional[int] = None
52
+
53
+ self._status = "ok"
54
+ self._output: JsonDict = {}
55
+ self._spans: List[Span] = []
56
+ self._error: Optional[JsonDict] = None
57
+
58
+ def __enter__(self) -> "RAGLensTrace":
59
+ self._started_at = utc_now_iso()
60
+ self._start_ms = now_ms()
61
+ return self
62
+
63
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
64
+ self._ended_at = utc_now_iso()
65
+
66
+ if self._start_ms is not None:
67
+ self._duration_ms = int(now_ms() - self._start_ms)
68
+
69
+ if exc is not None:
70
+ self._status = "error"
71
+ self._error = {
72
+ "type": exc_type.__name__ if exc_type else "Error",
73
+ "message": str(exc),
74
+ "stack": "".join(traceback.format_exception(exc_type, exc, tb)),
75
+ }
76
+
77
+ # Do not suppress exceptions.
78
+ return False
79
+
80
+ def retrieval(
81
+ self,
82
+ query: str,
83
+ chunks: List[JsonDict],
84
+ name: str = "retrieval",
85
+ top_k: Optional[int] = None,
86
+ metadata: Optional[JsonDict] = None,
87
+ ) -> None:
88
+ """
89
+ Record a retrieval span.
90
+
91
+ Args:
92
+ query: Query sent to retriever.
93
+ chunks: Retrieved chunks.
94
+ name: Human-readable span name.
95
+ top_k: Number of requested chunks.
96
+ metadata: Retriever metadata.
97
+ """
98
+ start = now_ms()
99
+ started_at = utc_now_iso()
100
+
101
+ normalized_chunks = self._normalize_chunks(chunks)
102
+
103
+ span_input: JsonDict = {
104
+ "query": query,
105
+ }
106
+
107
+ if top_k is not None:
108
+ span_input["top_k"] = top_k
109
+
110
+ span = Span(
111
+ span_id=new_id("span"),
112
+ trace_id=self.trace_id,
113
+ parent_span_id=None,
114
+ type="retrieval",
115
+ name=name,
116
+ status="ok",
117
+ input=span_input,
118
+ output={
119
+ "chunks": normalized_chunks,
120
+ },
121
+ metadata=metadata or {},
122
+ started_at=started_at,
123
+ ended_at=utc_now_iso(),
124
+ duration_ms=int(now_ms() - start),
125
+ error=None,
126
+ )
127
+
128
+ self._spans.append(span)
129
+
130
+ if self.query is None:
131
+ self.query = query
132
+
133
+ def llm(
134
+ self,
135
+ model: str,
136
+ prompt: Optional[str] = None,
137
+ response: Optional[str] = None,
138
+ messages: Optional[List[JsonDict]] = None,
139
+ name: str = "llm",
140
+ provider: Optional[str] = None,
141
+ input_tokens: Optional[int] = None,
142
+ output_tokens: Optional[int] = None,
143
+ latency_ms: Optional[int] = None,
144
+ metadata: Optional[JsonDict] = None,
145
+ ) -> None:
146
+ """
147
+ Record an LLM span.
148
+
149
+ Args:
150
+ model: Model name.
151
+ prompt: Prompt text.
152
+ response: Model response text.
153
+ messages: Optional chat messages.
154
+ name: Human-readable span name.
155
+ provider: LLM provider.
156
+ input_tokens: Input token count.
157
+ output_tokens: Output token count.
158
+ latency_ms: LLM call latency.
159
+ metadata: Additional metadata.
160
+ """
161
+ start = now_ms()
162
+ started_at = utc_now_iso()
163
+
164
+ span_input: JsonDict = {
165
+ "model": model,
166
+ }
167
+
168
+ if prompt is not None:
169
+ span_input["prompt"] = prompt
170
+
171
+ if messages is not None:
172
+ span_input["messages"] = messages
173
+
174
+ span_output: JsonDict = {}
175
+
176
+ if response is not None:
177
+ span_output["response"] = response
178
+ self._output["answer"] = response
179
+
180
+ span_metadata: JsonDict = metadata.copy() if metadata else {}
181
+
182
+ if provider is not None:
183
+ span_metadata["provider"] = provider
184
+
185
+ if input_tokens is not None:
186
+ span_metadata["input_tokens"] = input_tokens
187
+
188
+ if output_tokens is not None:
189
+ span_metadata["output_tokens"] = output_tokens
190
+
191
+ if input_tokens is not None and output_tokens is not None:
192
+ span_metadata["total_tokens"] = input_tokens + output_tokens
193
+
194
+ if latency_ms is not None:
195
+ span_metadata["latency_ms"] = latency_ms
196
+
197
+ span = Span(
198
+ span_id=new_id("span"),
199
+ trace_id=self.trace_id,
200
+ parent_span_id=None,
201
+ type="llm",
202
+ name=name,
203
+ status="ok",
204
+ input=span_input,
205
+ output=span_output,
206
+ metadata=span_metadata,
207
+ started_at=started_at,
208
+ ended_at=utc_now_iso(),
209
+ duration_ms=latency_ms if latency_ms is not None else int(now_ms() - start),
210
+ error=None,
211
+ )
212
+
213
+ self._spans.append(span)
214
+
215
+ def log_answer(self, answer: str) -> None:
216
+ """Record the final answer at trace level."""
217
+ self._output["answer"] = answer
218
+
219
+ def to_payload(self) -> TracePayload:
220
+ trace_input: JsonDict = {}
221
+
222
+ if self.query is not None:
223
+ trace_input["query"] = self.query
224
+
225
+ trace_metadata = {
226
+ "sdk_language": "python",
227
+ "sdk_version": "0.7.0",
228
+ **self.metadata,
229
+ }
230
+
231
+ if self._error is not None:
232
+ trace_metadata["error"] = self._error
233
+
234
+ record = TraceRecord(
235
+ trace_id=self.trace_id,
236
+ name=self.name,
237
+ status=self._status,
238
+ input=trace_input,
239
+ output=self._output,
240
+ metadata=trace_metadata,
241
+ started_at=self._started_at or utc_now_iso(),
242
+ ended_at=self._ended_at,
243
+ duration_ms=self._duration_ms,
244
+ )
245
+
246
+ return TracePayload(trace=record, spans=self._spans)
247
+
248
+ def to_dict(self) -> JsonDict:
249
+ return self.to_payload().to_dict()
250
+
251
+ def to_json(self, indent: int = 2) -> str:
252
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
253
+
254
+ def print_json(self) -> None:
255
+ print(self.to_json())
256
+
257
+ def flush(self, collector_url: Optional[str] = None, timeout: float = 5.0) -> JsonDict:
258
+ """
259
+ Send the trace payload to the local SledTrace collector.
260
+
261
+ Args:
262
+ collector_url: Optional collector base URL. Defaults to self.collector_url.
263
+ timeout: HTTP timeout in seconds.
264
+
265
+ Returns:
266
+ Collector JSON response.
267
+
268
+ Raises:
269
+ RuntimeError: If the collector request fails.
270
+ """
271
+ base_url = (collector_url or self.collector_url).rstrip("/")
272
+ url = f"{base_url}/api/traces"
273
+
274
+ data = json.dumps(self.to_dict()).encode("utf-8")
275
+
276
+ request = urllib.request.Request(
277
+ url=url,
278
+ data=data,
279
+ method="POST",
280
+ headers={
281
+ "Content-Type": "application/json",
282
+ "User-Agent": "sledtrace-python-sdk/0.7.0",
283
+ },
284
+ )
285
+
286
+ try:
287
+ with urllib.request.urlopen(request, timeout=timeout) as response:
288
+ response_body = response.read().decode("utf-8")
289
+ if not response_body:
290
+ return {}
291
+
292
+ return json.loads(response_body)
293
+
294
+ except urllib.error.HTTPError as exc:
295
+ body = exc.read().decode("utf-8", errors="replace")
296
+ raise RuntimeError(
297
+ f"SledTrace collector returned HTTP {exc.code}: {body}"
298
+ ) from exc
299
+
300
+ except urllib.error.URLError as exc:
301
+ raise RuntimeError(
302
+ f"Failed to connect to SledTrace collector at {url}: {exc.reason}"
303
+ ) from exc
304
+
305
+ def _normalize_chunks(self, chunks: List[JsonDict]) -> List[JsonDict]:
306
+ normalized: List[JsonDict] = []
307
+
308
+ for index, chunk in enumerate(chunks):
309
+ normalized_chunk = dict(chunk)
310
+
311
+ if "rank" not in normalized_chunk:
312
+ normalized_chunk["rank"] = index + 1
313
+
314
+ if "metadata" not in normalized_chunk or normalized_chunk["metadata"] is None:
315
+ normalized_chunk["metadata"] = {}
316
+
317
+ normalized.append(normalized_chunk)
318
+
319
+ return normalized
320
+
321
+
322
+ def trace(
323
+ name: str,
324
+ query: Optional[str] = None,
325
+ metadata: Optional[Dict[str, Any]] = None,
326
+ collector_url: Optional[str] = None,
327
+ ) -> RAGLensTrace:
328
+ """
329
+ Create a SledTrace trace context manager.
330
+ """
331
+ return RAGLensTrace(name=name, query=query, metadata=metadata, collector_url=collector_url,)
332
+
333
+
334
+ SledTraceTrace = RAGLensTrace
335
+
336
+ _DEFAULT_COLLECTOR_URL = "http://localhost:4319"
337
+ _LEGACY_COLLECTOR_ENV = "RAGLENS_COLLECTOR_URL"
338
+ _NEW_COLLECTOR_ENV = "SLEDTRACE_COLLECTOR_URL"
339
+ _legacy_env_notice_emitted = False
340
+
341
+
342
+ def resolve_collector_url(explicit_url: Optional[str]) -> str:
343
+ if explicit_url:
344
+ return explicit_url
345
+
346
+ new_value = os.getenv(_NEW_COLLECTOR_ENV)
347
+ if new_value:
348
+ return new_value
349
+
350
+ legacy_value = os.getenv(_LEGACY_COLLECTOR_ENV)
351
+ if legacy_value:
352
+ emit_legacy_env_warning_once()
353
+ return legacy_value
354
+
355
+ return _DEFAULT_COLLECTOR_URL
356
+
357
+
358
+ def emit_legacy_env_warning_once() -> None:
359
+ global _legacy_env_notice_emitted
360
+ if _legacy_env_notice_emitted:
361
+ return
362
+
363
+ _legacy_env_notice_emitted = True
364
+ print(
365
+ "DEPRECATED: RAGLENS_COLLECTOR_URL is deprecated; use SLEDTRACE_COLLECTOR_URL instead.",
366
+ file=sys.stderr,
367
+ )
sledtrace/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ import warnings
2
+
3
+ with warnings.catch_warnings():
4
+ warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"raglens.*")
5
+ from raglens import (
6
+ ChunkNormalizationError,
7
+ RAGLensTrace,
8
+ SledTraceTrace,
9
+ normalize_chunk,
10
+ normalize_chunks,
11
+ trace,
12
+ )
13
+
14
+ __version__ = "0.7.0"
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "trace",
19
+ "SledTraceTrace",
20
+ "RAGLensTrace",
21
+ "ChunkNormalizationError",
22
+ "normalize_chunk",
23
+ "normalize_chunks",
24
+ ]
sledtrace/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Iterable, Optional
8
+
9
+ from . import __version__
10
+
11
+
12
+ REPO_MARKERS = (
13
+ Path("AGENTS.md"),
14
+ Path("docker-compose.yml"),
15
+ Path("scripts") / "start-sledtrace.py",
16
+ )
17
+
18
+ SERVE_CHECKOUT_ERROR = (
19
+ "sledtrace serve currently requires a SledTrace source checkout. "
20
+ "Run it from the repository, or use Docker Compose from the repository root. "
21
+ "Standalone wheel-installed serving is not supported by this package."
22
+ )
23
+
24
+
25
+ def _candidate_directories(start: Path) -> Iterable[Path]:
26
+ current = start.resolve()
27
+ if current.is_file():
28
+ current = current.parent
29
+
30
+ yield current
31
+ yield from current.parents
32
+
33
+
34
+ def find_repo_root(start: Optional[Path] = None) -> Optional[Path]:
35
+ """Find a SledTrace source checkout at or above the starting directory."""
36
+ for candidate in _candidate_directories(start or Path.cwd()):
37
+ if all((candidate / marker).exists() for marker in REPO_MARKERS):
38
+ return candidate
39
+
40
+ return None
41
+
42
+
43
+ def serve() -> int:
44
+ repo_root = find_repo_root()
45
+ if repo_root is None:
46
+ print(SERVE_CHECKOUT_ERROR, file=sys.stderr)
47
+ return 1
48
+
49
+ startup_script = repo_root / "scripts" / "start-sledtrace.py"
50
+
51
+ print("Starting SledTrace local stack...")
52
+ return subprocess.call([sys.executable, str(startup_script)])
53
+
54
+
55
+ def show_version() -> int:
56
+ print(__version__)
57
+ return 0
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(
62
+ prog="sledtrace",
63
+ description="Inspect and run SledTrace local developer tooling.",
64
+ )
65
+ subparsers = parser.add_subparsers(dest="command")
66
+
67
+ serve_parser = subparsers.add_parser(
68
+ "serve",
69
+ help="Start the collector and dashboard from a SledTrace source checkout",
70
+ description=(
71
+ "Start the local collector and dashboard. This command must be run "
72
+ "from inside a SledTrace source checkout; standalone wheel-installed "
73
+ "serving is not supported by this package."
74
+ ),
75
+ )
76
+ serve_parser.set_defaults(func=lambda _args: serve())
77
+
78
+ version_parser = subparsers.add_parser(
79
+ "version",
80
+ help="Print the installed SledTrace package version",
81
+ description="Print the installed SledTrace package version.",
82
+ )
83
+ version_parser.set_defaults(func=lambda _args: show_version())
84
+
85
+ return parser
86
+
87
+
88
+ def main(argv: Optional[list[str]] = None) -> int:
89
+ parser = build_parser()
90
+ args = parser.parse_args(argv)
91
+
92
+ if not hasattr(args, "func"):
93
+ parser.print_help()
94
+ return 0
95
+
96
+ return args.func(args)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ raise SystemExit(main())
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: sledtrace
3
+ Version: 0.7.0
4
+ Summary: Local-first tracing and debugging SDK for RAG pipelines
5
+ Author: SledTrace Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Schromeo/SledTrace
8
+ Project-URL: Documentation, https://github.com/Schromeo/SledTrace#readme
9
+ Project-URL: Repository, https://github.com/Schromeo/SledTrace
10
+ Project-URL: Issues, https://github.com/Schromeo/SledTrace/issues
11
+ Keywords: rag,observability,tracing,llm,ai,debugging
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # SledTrace Python SDK
30
+
31
+ SledTrace is a local-first observability and debugging SDK for RAG pipelines.
32
+
33
+ Current release: **0.7.0 — External Developer Readiness**
34
+
35
+ Project and visual overview: [github.com/Schromeo/SledTrace](https://github.com/Schromeo/SledTrace)
36
+
37
+ ## Distribution status
38
+
39
+ Install the released SDK and CLI from production PyPI:
40
+
41
+ ```bash
42
+ python -m pip install sledtrace==0.7.0
43
+ ```
44
+
45
+ The immutable `0.7.0rc1` publication candidate remains available on [TestPyPI](https://test.pypi.org/project/sledtrace/0.7.0rc1/) for release-history purposes.
46
+
47
+ ## Install from source for development
48
+
49
+ ```bash
50
+ cd sdk/python
51
+ python -m pip install -e .
52
+ ```
53
+
54
+ ## Build a local wheel or sdist
55
+
56
+ ```bash
57
+ cd sdk/python
58
+ python -m pip install --upgrade pip
59
+ python -m pip install build
60
+ python -m build
61
+ ```
62
+
63
+ This produces wheel and source-distribution artifacts in `dist/`.
64
+
65
+ ## Install the built wheel
66
+
67
+ ```bash
68
+ python -m pip install dist/*.whl
69
+ ```
70
+
71
+ ## CLI
72
+
73
+ Editable and wheel installations provide:
74
+
75
+ ```bash
76
+ sledtrace --help
77
+ sledtrace serve --help
78
+ sledtrace version
79
+ ```
80
+
81
+ `sledtrace version` reports `0.7.0` for this release.
82
+
83
+ `sledtrace serve` must be run from inside a SledTrace source checkout. It locates the repository from the current working directory and delegates to `scripts/start-sledtrace.py`. The wheel does not bundle the Collector, Dashboard, Docker assets, or a standalone serving runtime; outside a checkout, `serve` exits with actionable guidance.
84
+
85
+ ## Basic usage
86
+
87
+ ```python
88
+ from sledtrace import trace
89
+
90
+ with trace("example") as t:
91
+ t.retrieval(
92
+ query="What is the refund policy?",
93
+ chunks=[
94
+ {
95
+ "id": "chunk-1",
96
+ "text": "Refunds are accepted within 30 days with proof of purchase.",
97
+ "score": 0.92,
98
+ "metadata": {"source": "refund_policy.md"},
99
+ }
100
+ ],
101
+ top_k=1,
102
+ )
103
+
104
+ t.llm(
105
+ model="demo-model",
106
+ prompt="Question: What is the refund policy?",
107
+ response="Refunds are accepted within 30 days with proof of purchase.",
108
+ provider="local-demo",
109
+ )
110
+
111
+ t.flush()
112
+ ```
113
+
114
+ ## Collector URL configuration
115
+
116
+ The default collector URL is `http://localhost:4319`.
117
+
118
+ Use the SledTrace environment variable:
119
+
120
+ ```bash
121
+ export SLEDTRACE_COLLECTOR_URL=http://localhost:4319
122
+ ```
123
+
124
+ PowerShell:
125
+
126
+ ```powershell
127
+ $env:SLEDTRACE_COLLECTOR_URL="http://localhost:4319"
128
+ ```
129
+
130
+ Legacy compatibility remains temporarily supported for migration:
131
+
132
+ ```bash
133
+ export RAGLENS_COLLECTOR_URL=http://localhost:4319
134
+ ```
135
+
136
+ The precedence is:
137
+
138
+ 1. `SLEDTRACE_COLLECTOR_URL`
139
+ 2. `RAGLENS_COLLECTOR_URL`
140
+ 3. `http://localhost:4319`
141
+
142
+ ## Legacy compatibility note
143
+
144
+ Legacy `raglens` imports remain temporarily supported during migration, but new code should use the SledTrace package path:
145
+
146
+ ```python
147
+ from sledtrace import trace
148
+ ```
149
+
150
+ ## More docs
151
+
152
+ - [Full project README and screenshots](https://github.com/Schromeo/SledTrace#readme)
153
+ - [User onboarding guide](https://github.com/Schromeo/SledTrace/blob/main/docs/product/USER_ONBOARDING.md)
154
+ - [Python SDK integration guide](https://github.com/Schromeo/SledTrace/blob/main/docs/integrations/PYTHON_SDK_GUIDE.md)
155
+ - [v0.7.0 release](https://github.com/Schromeo/SledTrace/releases/tag/v0.7.0)
156
+
157
+ Repository examples such as `examples.custom_pipeline_demo` are local developer examples and not a separate public SDK surface.
158
+
159
+
@@ -0,0 +1,13 @@
1
+ raglens/__init__.py,sha256=Z1XUqDRZZGWmQekzsrvyv2V-Ht5PdRVdGHxTq3vUHBY,534
2
+ raglens/chunks.py,sha256=aya7qsXMHfyZB1sCP5q1byhleYahnESqm2JHurwKlPg,12400
3
+ raglens/client.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ raglens/models.py,sha256=3dFtLmj2eI29kWihFsoYaHsv7jnuBJlzcZI8V83Yk8E,1837
5
+ raglens/trace.py,sha256=soxr-Sxcs_USGgdrnkQj34y7uvDio1ZhNAmIp1HuNOQ,10479
6
+ sledtrace/__init__.py,sha256=go1T0aqaDKtfxsWML7_m720eqNyJJpfFq1mxtEegZ14,504
7
+ sledtrace/cli.py,sha256=VDOJ5d3cqLweyNar0F2vrckJQCVoLfYvBDlyyIx8kNA,2758
8
+ sledtrace-0.7.0.dist-info/licenses/LICENSE,sha256=BNRnIjkX59ltbQ3sxGUnO6nXWVZYJRCe83qZcnnnNvU,1079
9
+ sledtrace-0.7.0.dist-info/METADATA,sha256=wmB0aAG2eaqXaD7-kFcoSYG0ICEze6yGSnU_Ba2ijQc,4529
10
+ sledtrace-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ sledtrace-0.7.0.dist-info/entry_points.txt,sha256=sYh3wzoWaFLUWUnclK-5eUjygRblXvypCG6PR9tN2CQ,49
12
+ sledtrace-0.7.0.dist-info/top_level.txt,sha256=lGkXnSSWTdqDYLx4vNPEtle-wL5YBjmR_7ZMraOdrhY,18
13
+ sledtrace-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sledtrace = sledtrace.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SledTrace contributors
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,2 @@
1
+ raglens
2
+ sledtrace