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.
@@ -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,5 @@
1
+ """RAG-Forge Observability: OpenTelemetry tracing, Langfuse, and drift detection."""
2
+
3
+ from rag_forge_observability.tracing import SpanAttributes, TracingManager
4
+
5
+ __all__ = ["SpanAttributes", "TracingManager"]
@@ -0,0 +1,3 @@
1
+ from rag_forge_observability.cli import main
2
+
3
+ main()
@@ -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()