rag-forge-observability 0.1.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.
- rag_forge_observability-0.1.0/.gitignore +62 -0
- rag_forge_observability-0.1.0/PKG-INFO +55 -0
- rag_forge_observability-0.1.0/README.md +30 -0
- rag_forge_observability-0.1.0/pyproject.toml +37 -0
- rag_forge_observability-0.1.0/src/rag_forge_observability/__init__.py +5 -0
- rag_forge_observability-0.1.0/src/rag_forge_observability/__main__.py +3 -0
- rag_forge_observability-0.1.0/src/rag_forge_observability/cli.py +94 -0
- rag_forge_observability-0.1.0/src/rag_forge_observability/drift.py +142 -0
- rag_forge_observability-0.1.0/src/rag_forge_observability/tracing.py +72 -0
- rag_forge_observability-0.1.0/tests/conftest.py +1 -0
- rag_forge_observability-0.1.0/tests/test_drift.py +82 -0
- rag_forge_observability-0.1.0/tests/test_drift_cli.py +66 -0
- rag_forge_observability-0.1.0/tests/test_tracing.py +68 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# Build outputs
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
*.tsbuildinfo
|
|
9
|
+
|
|
10
|
+
# Turborepo
|
|
11
|
+
.turbo/
|
|
12
|
+
|
|
13
|
+
# Python
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.py[cod]
|
|
16
|
+
*$py.class
|
|
17
|
+
*.egg-info/
|
|
18
|
+
*.egg
|
|
19
|
+
.venv/
|
|
20
|
+
.python-version-local
|
|
21
|
+
|
|
22
|
+
# Python tools
|
|
23
|
+
.mypy_cache/
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
htmlcov/
|
|
27
|
+
.coverage
|
|
28
|
+
.coverage.*
|
|
29
|
+
|
|
30
|
+
# Environment variables
|
|
31
|
+
.env
|
|
32
|
+
.env.local
|
|
33
|
+
.env.*.local
|
|
34
|
+
|
|
35
|
+
# IDE
|
|
36
|
+
.vscode/
|
|
37
|
+
.idea/
|
|
38
|
+
*.swp
|
|
39
|
+
*.swo
|
|
40
|
+
*~
|
|
41
|
+
|
|
42
|
+
# OS
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
desktop.ini
|
|
46
|
+
|
|
47
|
+
# Test & coverage
|
|
48
|
+
coverage/
|
|
49
|
+
*.lcov
|
|
50
|
+
|
|
51
|
+
# Logs
|
|
52
|
+
*.log
|
|
53
|
+
npm-debug.log*
|
|
54
|
+
pnpm-debug.log*
|
|
55
|
+
|
|
56
|
+
.claude/
|
|
57
|
+
|
|
58
|
+
# Next.js
|
|
59
|
+
apps/*/.next
|
|
60
|
+
apps/*/out
|
|
61
|
+
apps/*/next-env.d.ts
|
|
62
|
+
.vercel
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rag-forge-observability
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Observability stack: OpenTelemetry tracing, Langfuse integration, and drift detection
|
|
5
|
+
Project-URL: Homepage, https://github.com/hallengray/rag-forge
|
|
6
|
+
Project-URL: Repository, https://github.com/hallengray/rag-forge
|
|
7
|
+
Project-URL: Issues, https://github.com/hallengray/rag-forge/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/hallengray/rag-forge#readme
|
|
9
|
+
Author: Femi Adedayo
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: drift-detection,observability,opentelemetry,rag,tracing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: opentelemetry-api>=1.20
|
|
21
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20
|
|
22
|
+
Requires-Dist: opentelemetry-sdk>=1.20
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# rag-forge-observability
|
|
27
|
+
|
|
28
|
+
OpenTelemetry tracing and query drift detection for the RAG-Forge toolkit.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install rag-forge-observability
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from rag_forge_observability.drift import DriftDetector, DriftBaseline
|
|
40
|
+
|
|
41
|
+
baseline = DriftBaseline(embeddings=[[1.0, 0.0, 0.0]])
|
|
42
|
+
detector = DriftDetector(threshold=0.15)
|
|
43
|
+
report = detector.analyze(current_embeddings=[[0.9, 0.1, 0.0]], baseline=baseline)
|
|
44
|
+
print(f"Drift detected: {report.is_drifting}")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- OpenTelemetry tracing for all RAG pipeline stages
|
|
50
|
+
- Query drift detection with baseline comparison
|
|
51
|
+
- Centroid-based cosine distance analysis
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# rag-forge-observability
|
|
2
|
+
|
|
3
|
+
OpenTelemetry tracing and query drift detection for the RAG-Forge toolkit.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install rag-forge-observability
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from rag_forge_observability.drift import DriftDetector, DriftBaseline
|
|
15
|
+
|
|
16
|
+
baseline = DriftBaseline(embeddings=[[1.0, 0.0, 0.0]])
|
|
17
|
+
detector = DriftDetector(threshold=0.15)
|
|
18
|
+
report = detector.analyze(current_embeddings=[[0.9, 0.1, 0.0]], baseline=baseline)
|
|
19
|
+
print(f"Drift detected: {report.is_drifting}")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- OpenTelemetry tracing for all RAG pipeline stages
|
|
25
|
+
- Query drift detection with baseline comparison
|
|
26
|
+
- Centroid-based cosine distance analysis
|
|
27
|
+
|
|
28
|
+
## License
|
|
29
|
+
|
|
30
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rag-forge-observability"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Observability stack: OpenTelemetry tracing, Langfuse integration, and drift detection"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Femi Adedayo" }]
|
|
8
|
+
keywords = ["rag", "observability", "opentelemetry", "drift-detection", "tracing"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Topic :: System :: Monitoring",
|
|
17
|
+
]
|
|
18
|
+
readme = "README.md"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"pydantic>=2.0",
|
|
21
|
+
"opentelemetry-api>=1.20",
|
|
22
|
+
"opentelemetry-sdk>=1.20",
|
|
23
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.20",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/hallengray/rag-forge"
|
|
28
|
+
Repository = "https://github.com/hallengray/rag-forge"
|
|
29
|
+
Issues = "https://github.com/hallengray/rag-forge/issues"
|
|
30
|
+
Documentation = "https://github.com/hallengray/rag-forge#readme"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/rag_forge_observability"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Python CLI entry point for observability commands.
|
|
2
|
+
|
|
3
|
+
Called via: uv run python -m rag_forge_observability.cli drift-report --current ... --baseline ...
|
|
4
|
+
Outputs JSON to stdout for the TypeScript CLI to parse.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rag_forge_observability.drift import DriftBaseline, DriftDetector
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_drift_save_baseline(
|
|
17
|
+
embeddings_path: str,
|
|
18
|
+
baseline_path: str,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Save embeddings as a drift baseline."""
|
|
21
|
+
try:
|
|
22
|
+
path = Path(embeddings_path)
|
|
23
|
+
with path.open() as f:
|
|
24
|
+
data = json.load(f)
|
|
25
|
+
embeddings = data["embeddings"]
|
|
26
|
+
|
|
27
|
+
detector = DriftDetector()
|
|
28
|
+
detector.save_baseline(embeddings, baseline_path)
|
|
29
|
+
return {
|
|
30
|
+
"success": True,
|
|
31
|
+
"baseline_path": baseline_path,
|
|
32
|
+
"vectors_saved": len(embeddings),
|
|
33
|
+
}
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return {"success": False, "error": str(e)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_drift_report(
|
|
39
|
+
current_path: str,
|
|
40
|
+
baseline_path: str,
|
|
41
|
+
threshold: float = 0.15,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Generate a drift report comparing current embeddings to baseline."""
|
|
44
|
+
try:
|
|
45
|
+
baseline = DriftBaseline.load(baseline_path)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return {"success": False, "error": str(e)}
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
with Path(current_path).open() as f:
|
|
51
|
+
data = json.load(f)
|
|
52
|
+
current = data["embeddings"]
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return {"success": False, "error": f"Failed to load current embeddings: {e}"}
|
|
55
|
+
|
|
56
|
+
detector = DriftDetector(threshold=threshold)
|
|
57
|
+
report = detector.analyze(current_embeddings=current, baseline=baseline)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"success": True,
|
|
61
|
+
"is_drifting": report.is_drifting,
|
|
62
|
+
"distance": report.baseline_distance,
|
|
63
|
+
"threshold": report.threshold,
|
|
64
|
+
"details": report.details,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main() -> None:
|
|
69
|
+
"""Main entry point."""
|
|
70
|
+
parser = argparse.ArgumentParser(prog="rag-forge-observability")
|
|
71
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
72
|
+
|
|
73
|
+
save_parser = subparsers.add_parser("drift-save-baseline", help="Save drift baseline")
|
|
74
|
+
save_parser.add_argument("--embeddings", required=True, help="Path to embeddings JSON")
|
|
75
|
+
save_parser.add_argument("--output", required=True, help="Path to save baseline")
|
|
76
|
+
|
|
77
|
+
report_parser = subparsers.add_parser("drift-report", help="Generate drift report")
|
|
78
|
+
report_parser.add_argument("--current", required=True, help="Path to current embeddings JSON")
|
|
79
|
+
report_parser.add_argument("--baseline", required=True, help="Path to baseline JSON")
|
|
80
|
+
report_parser.add_argument("--threshold", type=float, default=0.15, help="Drift threshold")
|
|
81
|
+
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
if args.command == "drift-save-baseline":
|
|
84
|
+
result = cmd_drift_save_baseline(args.embeddings, args.output)
|
|
85
|
+
elif args.command == "drift-report":
|
|
86
|
+
result = cmd_drift_report(args.current, args.baseline, args.threshold)
|
|
87
|
+
else:
|
|
88
|
+
result = {"success": False, "error": f"Unknown command: {args.command}"}
|
|
89
|
+
|
|
90
|
+
json.dump(result, sys.stdout)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Query drift detection and alerting.
|
|
2
|
+
|
|
3
|
+
Compares current query embedding distribution against a saved baseline
|
|
4
|
+
using centroid cosine distance. Alerts when distance exceeds threshold.
|
|
5
|
+
PRD spec: cosine distance > 0.15 from baseline triggers drift alert.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DriftReport:
|
|
16
|
+
"""Result of a drift analysis."""
|
|
17
|
+
|
|
18
|
+
baseline_distance: float
|
|
19
|
+
is_drifting: bool
|
|
20
|
+
threshold: float
|
|
21
|
+
details: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DriftBaseline:
|
|
25
|
+
"""Stored baseline of query embeddings for drift comparison."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, embeddings: list[list[float]]) -> None:
|
|
28
|
+
if not embeddings:
|
|
29
|
+
msg = "Baseline requires at least one embedding vector."
|
|
30
|
+
raise ValueError(msg)
|
|
31
|
+
dim = len(embeddings[0])
|
|
32
|
+
if dim == 0:
|
|
33
|
+
msg = "Embedding vectors must not be empty."
|
|
34
|
+
raise ValueError(msg)
|
|
35
|
+
if any(len(emb) != dim for emb in embeddings):
|
|
36
|
+
msg = f"All embeddings must have the same dimension ({dim})."
|
|
37
|
+
raise ValueError(msg)
|
|
38
|
+
self.embeddings = embeddings
|
|
39
|
+
self._centroid: list[float] | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def centroid(self) -> list[float]:
|
|
43
|
+
"""Compute the mean (centroid) of all baseline embeddings."""
|
|
44
|
+
if self._centroid is not None:
|
|
45
|
+
return self._centroid
|
|
46
|
+
dim = len(self.embeddings[0])
|
|
47
|
+
n = len(self.embeddings)
|
|
48
|
+
centroid = [0.0] * dim
|
|
49
|
+
for emb in self.embeddings:
|
|
50
|
+
for i, val in enumerate(emb):
|
|
51
|
+
centroid[i] += val
|
|
52
|
+
self._centroid = [c / n for c in centroid]
|
|
53
|
+
return self._centroid
|
|
54
|
+
|
|
55
|
+
def save(self, path: str | Path) -> None:
|
|
56
|
+
"""Save baseline embeddings to a JSON file."""
|
|
57
|
+
path = Path(path)
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
with path.open("w") as f:
|
|
60
|
+
json.dump({"embeddings": self.embeddings}, f)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def load(cls, path: str | Path) -> "DriftBaseline":
|
|
64
|
+
"""Load baseline embeddings from a JSON file."""
|
|
65
|
+
path = Path(path)
|
|
66
|
+
if not path.exists():
|
|
67
|
+
msg = f"Baseline file not found: {path}"
|
|
68
|
+
raise FileNotFoundError(msg)
|
|
69
|
+
with path.open() as f:
|
|
70
|
+
data = json.load(f)
|
|
71
|
+
return cls(embeddings=data["embeddings"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cosine_distance(a: list[float], b: list[float]) -> float:
|
|
75
|
+
"""Compute cosine distance (1 - cosine_similarity) between two vectors."""
|
|
76
|
+
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
|
77
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
78
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
79
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
80
|
+
return 1.0
|
|
81
|
+
similarity = dot / (norm_a * norm_b)
|
|
82
|
+
return 1.0 - similarity
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DriftDetector:
|
|
86
|
+
"""Detects query distribution drift from a baseline.
|
|
87
|
+
|
|
88
|
+
Computes the centroid of current query embeddings and measures
|
|
89
|
+
cosine distance from the baseline centroid. Alerts when the
|
|
90
|
+
distance exceeds the configured threshold (default: 0.15).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, threshold: float = 0.15) -> None:
|
|
94
|
+
self.threshold = threshold
|
|
95
|
+
|
|
96
|
+
def analyze(
|
|
97
|
+
self,
|
|
98
|
+
current_embeddings: list[list[float]],
|
|
99
|
+
baseline: DriftBaseline,
|
|
100
|
+
) -> DriftReport:
|
|
101
|
+
"""Compare current query embeddings against the baseline."""
|
|
102
|
+
if not current_embeddings:
|
|
103
|
+
return DriftReport(
|
|
104
|
+
baseline_distance=0.0,
|
|
105
|
+
is_drifting=False,
|
|
106
|
+
threshold=self.threshold,
|
|
107
|
+
details="No current embeddings to analyze.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
dim = len(current_embeddings[0])
|
|
111
|
+
n = len(current_embeddings)
|
|
112
|
+
current_centroid = [0.0] * dim
|
|
113
|
+
for emb in current_embeddings:
|
|
114
|
+
for i, val in enumerate(emb):
|
|
115
|
+
current_centroid[i] += val
|
|
116
|
+
current_centroid = [c / n for c in current_centroid]
|
|
117
|
+
|
|
118
|
+
distance = _cosine_distance(baseline.centroid, current_centroid)
|
|
119
|
+
is_drifting = distance > self.threshold
|
|
120
|
+
|
|
121
|
+
details = (
|
|
122
|
+
f"Centroid cosine distance: {distance:.4f} "
|
|
123
|
+
f"(threshold: {self.threshold:.4f}). "
|
|
124
|
+
f"{'DRIFT DETECTED' if is_drifting else 'Within normal range'}. "
|
|
125
|
+
f"Baseline: {len(baseline.embeddings)} vectors, Current: {n} vectors."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return DriftReport(
|
|
129
|
+
baseline_distance=distance,
|
|
130
|
+
is_drifting=is_drifting,
|
|
131
|
+
threshold=self.threshold,
|
|
132
|
+
details=details,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def save_baseline(
|
|
136
|
+
self,
|
|
137
|
+
embeddings: list[list[float]],
|
|
138
|
+
path: str | Path,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Save embeddings as a new baseline."""
|
|
141
|
+
baseline = DriftBaseline(embeddings=embeddings)
|
|
142
|
+
baseline.save(path)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""OpenTelemetry tracing instrumentation for RAG pipeline stages."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from opentelemetry import trace
|
|
7
|
+
from opentelemetry.sdk.resources import Resource
|
|
8
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
9
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SpanAttributes:
|
|
14
|
+
"""Standardized attributes for pipeline stage spans."""
|
|
15
|
+
|
|
16
|
+
stage: str
|
|
17
|
+
duration_ms: float | None = None
|
|
18
|
+
token_count: int | None = None
|
|
19
|
+
chunk_count: int | None = None
|
|
20
|
+
model_used: str | None = None
|
|
21
|
+
cost_usd: float | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TracingManager:
|
|
25
|
+
"""Initializes OpenTelemetry tracing based on environment configuration.
|
|
26
|
+
|
|
27
|
+
Tracing activates when OTEL_EXPORTER_OTLP_ENDPOINT is set.
|
|
28
|
+
When not set, returns the OTEL no-op tracer (zero overhead).
|
|
29
|
+
|
|
30
|
+
Langfuse integration: set OTEL_EXPORTER_OTLP_ENDPOINT to
|
|
31
|
+
https://cloud.langfuse.com/api/public/otel with LANGFUSE_PUBLIC_KEY
|
|
32
|
+
and LANGFUSE_SECRET_KEY env vars.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, service_name: str = "rag-forge") -> None:
|
|
36
|
+
self.service_name = service_name
|
|
37
|
+
self._enabled = False
|
|
38
|
+
self._provider: TracerProvider | None = None
|
|
39
|
+
|
|
40
|
+
def enable(self) -> None:
|
|
41
|
+
"""Initialize OpenTelemetry tracing if OTLP endpoint is configured."""
|
|
42
|
+
if self._enabled:
|
|
43
|
+
return
|
|
44
|
+
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
45
|
+
if not endpoint:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
49
|
+
OTLPSpanExporter,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
resource = Resource.create({"service.name": self.service_name})
|
|
53
|
+
self._provider = TracerProvider(resource=resource)
|
|
54
|
+
exporter = OTLPSpanExporter(endpoint=endpoint)
|
|
55
|
+
self._provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
56
|
+
trace.set_tracer_provider(self._provider)
|
|
57
|
+
self._enabled = True
|
|
58
|
+
|
|
59
|
+
def is_enabled(self) -> bool:
|
|
60
|
+
"""Check if tracing is active."""
|
|
61
|
+
return self._enabled
|
|
62
|
+
|
|
63
|
+
def get_tracer(self, name: str = "rag-forge") -> trace.Tracer:
|
|
64
|
+
"""Return a tracer instance. No-op tracer if tracing is not enabled."""
|
|
65
|
+
return trace.get_tracer(name)
|
|
66
|
+
|
|
67
|
+
def shutdown(self) -> None:
|
|
68
|
+
"""Flush pending spans and shut down the tracer provider."""
|
|
69
|
+
if self._provider is not None:
|
|
70
|
+
self._provider.shutdown()
|
|
71
|
+
self._provider = None
|
|
72
|
+
self._enabled = False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared pytest fixtures for rag-forge-observability tests."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Tests for query drift detection."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from rag_forge_observability.drift import DriftBaseline, DriftDetector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestDriftBaseline:
|
|
12
|
+
def test_compute_centroid(self) -> None:
|
|
13
|
+
baseline = DriftBaseline(embeddings=[[1.0, 0.0], [0.0, 1.0]])
|
|
14
|
+
centroid = baseline.centroid
|
|
15
|
+
assert len(centroid) == 2
|
|
16
|
+
assert math.isclose(centroid[0], 0.5, abs_tol=1e-9)
|
|
17
|
+
assert math.isclose(centroid[1], 0.5, abs_tol=1e-9)
|
|
18
|
+
|
|
19
|
+
def test_save_and_load(self, tmp_path: Path) -> None:
|
|
20
|
+
baseline = DriftBaseline(embeddings=[[1.0, 2.0], [3.0, 4.0]])
|
|
21
|
+
path = tmp_path / "baseline.json"
|
|
22
|
+
baseline.save(path)
|
|
23
|
+
loaded = DriftBaseline.load(path)
|
|
24
|
+
assert loaded.embeddings == baseline.embeddings
|
|
25
|
+
|
|
26
|
+
def test_load_missing_file_raises(self, tmp_path: Path) -> None:
|
|
27
|
+
with pytest.raises(FileNotFoundError):
|
|
28
|
+
DriftBaseline.load(tmp_path / "missing.json")
|
|
29
|
+
|
|
30
|
+
def test_empty_embeddings_raises(self) -> None:
|
|
31
|
+
with pytest.raises(ValueError, match="at least one embedding"):
|
|
32
|
+
DriftBaseline(embeddings=[])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestDriftDetector:
|
|
36
|
+
def test_no_drift_when_identical(self) -> None:
|
|
37
|
+
baseline = DriftBaseline(embeddings=[[1.0, 0.0, 0.0]])
|
|
38
|
+
detector = DriftDetector(threshold=0.15)
|
|
39
|
+
report = detector.analyze(
|
|
40
|
+
current_embeddings=[[1.0, 0.0, 0.0]],
|
|
41
|
+
baseline=baseline,
|
|
42
|
+
)
|
|
43
|
+
assert not report.is_drifting
|
|
44
|
+
assert report.baseline_distance < 0.15
|
|
45
|
+
|
|
46
|
+
def test_drift_detected_when_orthogonal(self) -> None:
|
|
47
|
+
baseline = DriftBaseline(embeddings=[[1.0, 0.0, 0.0]])
|
|
48
|
+
detector = DriftDetector(threshold=0.15)
|
|
49
|
+
report = detector.analyze(
|
|
50
|
+
current_embeddings=[[0.0, 1.0, 0.0]],
|
|
51
|
+
baseline=baseline,
|
|
52
|
+
)
|
|
53
|
+
assert report.is_drifting
|
|
54
|
+
assert report.baseline_distance > 0.15
|
|
55
|
+
|
|
56
|
+
def test_custom_threshold(self) -> None:
|
|
57
|
+
baseline = DriftBaseline(embeddings=[[1.0, 0.0]])
|
|
58
|
+
detector = DriftDetector(threshold=0.99)
|
|
59
|
+
report = detector.analyze(
|
|
60
|
+
current_embeddings=[[0.9, 0.1]],
|
|
61
|
+
baseline=baseline,
|
|
62
|
+
)
|
|
63
|
+
assert isinstance(report.is_drifting, bool)
|
|
64
|
+
|
|
65
|
+
def test_report_includes_details(self) -> None:
|
|
66
|
+
baseline = DriftBaseline(embeddings=[[1.0, 0.0]])
|
|
67
|
+
detector = DriftDetector(threshold=0.15)
|
|
68
|
+
report = detector.analyze(
|
|
69
|
+
current_embeddings=[[0.0, 1.0]],
|
|
70
|
+
baseline=baseline,
|
|
71
|
+
)
|
|
72
|
+
assert report.threshold == 0.15
|
|
73
|
+
assert report.details is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestDriftDetectorSaveBaseline:
|
|
77
|
+
def test_save_baseline_from_embeddings(self, tmp_path: Path) -> None:
|
|
78
|
+
detector = DriftDetector(threshold=0.15)
|
|
79
|
+
path = tmp_path / "baseline.json"
|
|
80
|
+
detector.save_baseline([[1.0, 2.0], [3.0, 4.0]], path)
|
|
81
|
+
loaded = DriftBaseline.load(path)
|
|
82
|
+
assert len(loaded.embeddings) == 2
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tests for the drift CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rag_forge_observability.cli import cmd_drift_report, cmd_drift_save_baseline
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestDriftCLI:
|
|
10
|
+
def test_save_baseline_creates_file(self, tmp_path: Path) -> None:
|
|
11
|
+
embeddings_file = tmp_path / "embeddings.json"
|
|
12
|
+
embeddings_file.write_text(json.dumps({"embeddings": [[1.0, 0.0], [0.0, 1.0]]}))
|
|
13
|
+
baseline_path = tmp_path / "baseline.json"
|
|
14
|
+
|
|
15
|
+
result = cmd_drift_save_baseline(
|
|
16
|
+
embeddings_path=str(embeddings_file),
|
|
17
|
+
baseline_path=str(baseline_path),
|
|
18
|
+
)
|
|
19
|
+
assert result["success"] is True
|
|
20
|
+
assert baseline_path.exists()
|
|
21
|
+
|
|
22
|
+
def test_report_no_drift(self, tmp_path: Path) -> None:
|
|
23
|
+
baseline_path = tmp_path / "baseline.json"
|
|
24
|
+
baseline_data = {"embeddings": [[1.0, 0.0], [0.9, 0.1]]}
|
|
25
|
+
baseline_path.write_text(json.dumps(baseline_data))
|
|
26
|
+
|
|
27
|
+
current_path = tmp_path / "current.json"
|
|
28
|
+
current_data = {"embeddings": [[1.0, 0.0], [0.95, 0.05]]}
|
|
29
|
+
current_path.write_text(json.dumps(current_data))
|
|
30
|
+
|
|
31
|
+
result = cmd_drift_report(
|
|
32
|
+
current_path=str(current_path),
|
|
33
|
+
baseline_path=str(baseline_path),
|
|
34
|
+
threshold=0.15,
|
|
35
|
+
)
|
|
36
|
+
assert result["success"] is True
|
|
37
|
+
assert result["is_drifting"] is False
|
|
38
|
+
|
|
39
|
+
def test_report_with_drift(self, tmp_path: Path) -> None:
|
|
40
|
+
baseline_path = tmp_path / "baseline.json"
|
|
41
|
+
baseline_data = {"embeddings": [[1.0, 0.0]]}
|
|
42
|
+
baseline_path.write_text(json.dumps(baseline_data))
|
|
43
|
+
|
|
44
|
+
current_path = tmp_path / "current.json"
|
|
45
|
+
current_data = {"embeddings": [[0.0, 1.0]]}
|
|
46
|
+
current_path.write_text(json.dumps(current_data))
|
|
47
|
+
|
|
48
|
+
result = cmd_drift_report(
|
|
49
|
+
current_path=str(current_path),
|
|
50
|
+
baseline_path=str(baseline_path),
|
|
51
|
+
threshold=0.15,
|
|
52
|
+
)
|
|
53
|
+
assert result["success"] is True
|
|
54
|
+
assert result["is_drifting"] is True
|
|
55
|
+
|
|
56
|
+
def test_report_missing_baseline_returns_error(self, tmp_path: Path) -> None:
|
|
57
|
+
current_path = tmp_path / "current.json"
|
|
58
|
+
current_path.write_text(json.dumps({"embeddings": [[1.0, 0.0]]}))
|
|
59
|
+
|
|
60
|
+
result = cmd_drift_report(
|
|
61
|
+
current_path=str(current_path),
|
|
62
|
+
baseline_path=str(tmp_path / "missing.json"),
|
|
63
|
+
threshold=0.15,
|
|
64
|
+
)
|
|
65
|
+
assert result["success"] is False
|
|
66
|
+
assert "error" in result
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tests for TracingManager with OpenTelemetry."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from opentelemetry import trace
|
|
7
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
8
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
9
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
10
|
+
|
|
11
|
+
from rag_forge_observability.tracing import TracingManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestTracingManager:
|
|
15
|
+
def test_default_disabled(self) -> None:
|
|
16
|
+
manager = TracingManager()
|
|
17
|
+
assert not manager.is_enabled()
|
|
18
|
+
|
|
19
|
+
def test_enable_without_endpoint_stays_disabled(self) -> None:
|
|
20
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
21
|
+
os.environ.pop("OTEL_EXPORTER_OTLP_ENDPOINT", None)
|
|
22
|
+
manager = TracingManager()
|
|
23
|
+
manager.enable()
|
|
24
|
+
assert not manager.is_enabled()
|
|
25
|
+
|
|
26
|
+
def test_enable_with_endpoint_enables(self) -> None:
|
|
27
|
+
with patch.dict(os.environ, {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}):
|
|
28
|
+
manager = TracingManager()
|
|
29
|
+
manager.enable()
|
|
30
|
+
assert manager.is_enabled()
|
|
31
|
+
manager.shutdown()
|
|
32
|
+
assert not manager.is_enabled()
|
|
33
|
+
|
|
34
|
+
def test_custom_service_name(self) -> None:
|
|
35
|
+
manager = TracingManager(service_name="my-rag-app")
|
|
36
|
+
assert manager.service_name == "my-rag-app"
|
|
37
|
+
|
|
38
|
+
def test_get_tracer_returns_tracer(self) -> None:
|
|
39
|
+
manager = TracingManager()
|
|
40
|
+
tracer = manager.get_tracer()
|
|
41
|
+
assert isinstance(tracer, trace.Tracer)
|
|
42
|
+
|
|
43
|
+
def test_get_tracer_when_disabled_returns_noop(self) -> None:
|
|
44
|
+
manager = TracingManager()
|
|
45
|
+
tracer = manager.get_tracer()
|
|
46
|
+
with tracer.start_as_current_span("test") as span:
|
|
47
|
+
assert span is not None
|
|
48
|
+
|
|
49
|
+
def test_shutdown_without_enable(self) -> None:
|
|
50
|
+
manager = TracingManager()
|
|
51
|
+
manager.shutdown()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestTracingWithInMemoryExporter:
|
|
55
|
+
def test_spans_are_emitted(self) -> None:
|
|
56
|
+
exporter = InMemorySpanExporter()
|
|
57
|
+
provider = TracerProvider()
|
|
58
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
59
|
+
tracer = provider.get_tracer("test")
|
|
60
|
+
|
|
61
|
+
with tracer.start_as_current_span("test-span") as span:
|
|
62
|
+
span.set_attribute("test.attr", "value")
|
|
63
|
+
|
|
64
|
+
spans = exporter.get_finished_spans()
|
|
65
|
+
assert len(spans) == 1
|
|
66
|
+
assert spans[0].name == "test-span"
|
|
67
|
+
assert spans[0].attributes["test.attr"] == "value"
|
|
68
|
+
provider.shutdown()
|