Files
NSCT Agent 08ec0e290c feat(stage16): implement observability — structured logging, metrics, tracing
- JSONFormatter, ContextVars, set_research_run_id() with short logger names
- self-built Counter/Histogram/Gauge system (no external deps)
- Prometheus text export at /metrics
- Request logging middleware with X-Request-ID
- Metrics instrumentation: search_queries, sources_fetched, claims, contradictions
- Histograms: research_duration, llm_request_duration
- Gauge: active_research_runs
- 13 + 21 = 34 tests
2026-08-27 19:44:46 +00:00

296 lines
10 KiB
Python

"""Tests for NSCT metrics system (self-built, no prometheus-client required)."""
from __future__ import annotations
import os
import threading
import time
import pytest
# Ensure src is on path
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from nsct import metrics
class TestCounters:
"""Test counter operations."""
def setup_method(self) -> None:
"""Reset counters before each test."""
from nsct.metrics import _counters, _lock
with _lock:
_counters.clear()
def test_increment(self) -> None:
"""Counter erhöht sich um 1."""
val = metrics.increment("test_counter")
assert val == 1
val = metrics.increment("test_counter")
assert val == 2
def test_increment_amount(self) -> None:
"""Counter erhöht sich um den angegebenen Betrag."""
val = metrics.increment("test_counter", 5)
assert val == 5
val = metrics.increment("test_counter", 3)
assert val == 8
def test_get_counter_zero(self) -> None:
"""Nicht existenter Counter gibt 0 zurück."""
assert metrics.get_counter("nonexistent") == 0
def test_counter_persistence(self) -> None:
"""Counter-Wert wird über mehrere Aufrufe beibehalten."""
metrics.increment("persist", 10)
assert metrics.get_counter("persist") == 10
class TestHistograms:
"""Test histogram operations."""
def setup_method(self) -> None:
from nsct.metrics import _histograms, _lock
with _lock:
_histograms.clear()
def test_observe(self) -> None:
"""Beobachtung wird im Histogramm gespeichert."""
metrics.observe("request_seconds", 0.5)
val = metrics.get_counter("request_seconds_count") if hasattr(metrics, 'get_counter') else None
assert metrics.observe("request_seconds", 1.0) == 1.0
def test_observe_bucket(self) -> None:
"""Beobachtungen werden in die korrekten Buckets gezählt."""
metrics.observe("test_hist", 0.3)
metrics.observe("test_hist", 0.8)
metrics.observe("test_hist", 2.5)
from nsct.metrics import _histograms, _lock
with _lock:
h = _histograms["test_hist"]
assert h["_count"] == 3
assert h["_sum"] == 3.6
def test_histogram_context_manager(self) -> None:
"""Context manager misst die Dauer korrekt."""
from nsct.metrics import _histograms, _lock
# Clear histogram state
with _lock:
_histograms["hist_test"] = {"_count": 0, "_sum": 0.0, "buckets": {str(b): 0 for b in (0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 25.0, 50.0, 100.0)}}
with metrics.histogram("hist_test"):
time.sleep(0.05)
with _lock:
h = _histograms["hist_test"]
assert h["_count"] == 1
assert h["_sum"] >= 0.05
def test_default_buckets(self) -> None:
"""Histogram hat die Standard-Buckets."""
from nsct.metrics import _histograms, _lock
metrics.observe("bucket_test", 0.5)
with _lock:
h = _histograms["bucket_test"]
assert "0.5" in h["buckets"]
assert "1.0" in h["buckets"]
class TestGauges:
"""Test gauge operations."""
def setup_method(self) -> None:
from nsct.metrics import _gauges, _lock
with _lock:
_gauges.clear()
def test_gauge_set_and_get(self) -> None:
"""Gauge-Wert wird gesetzt und gelesen."""
metrics.gauge("active_runs", 5)
assert metrics.get_gauge("active_runs") == 5.0
def test_gauge_default_zero(self) -> None:
"""Nicht gesetzter Gauge gibt 0.0 zurück."""
assert metrics.get_gauge("nonexistent") == 0.0
def test_gauge_overwrite(self) -> None:
"""Gauge-Wert wird überschrieben."""
metrics.gauge("x", 1)
metrics.gauge("x", 10)
assert metrics.get_gauge("x") == 10.0
class TestPrometheusExport:
"""Test Prometheus text format rendering."""
def setup_method(self) -> None:
from nsct.metrics import _counters, _histograms, _gauges, _lock
with _lock:
_counters.clear()
_histograms.clear()
_gauges.clear()
def test_render_empty(self) -> None:
"""Leere Metrics geben nur eine leere Zeile zurück."""
text = metrics.render_prometheus()
# Empty string or just newline is acceptable
assert text.strip() == ""
def test_render_counter(self) -> None:
"""Counter werden korrekt exportiert."""
metrics.increment("my_counter", 42)
text = metrics.render_prometheus()
assert "my_counter 42" in text
assert "# HELP my_counter" in text
assert "# TYPE my_counter counter" in text
def test_render_histogram(self) -> None:
"""Histogram wird korrekt exportiert."""
metrics.observe("my_hist", 0.1)
metrics.observe("my_hist", 1.0)
text = metrics.render_prometheus()
assert "my_hist_count 2" in text
assert "my_hist_sum 1.1" in text
assert "# TYPE my_hist histogram" in text
assert 'my_hist_bucket{le="+Inf"}' in text
def test_render_gauge(self) -> None:
"""Gauge wird korrekt exportiert."""
metrics.gauge("my_gauge", 7)
text = metrics.render_prometheus()
assert "my_gauge 7.0" in text
assert "# TYPE my_gauge gauge" in text
def test_render_combined(self) -> None:
"""Kombinierter Export aller Metrik-Typen."""
metrics.increment("search_queries_total", 100)
metrics.increment("sources_fetched_total", 50)
metrics.increment("claims_extracted_total", 25)
metrics.increment("contradictions_detected_total", 5)
metrics.increment("research_completed_total", 8)
metrics.increment("research_failed_total", 2)
metrics.observe("research_duration_seconds", 2.5)
metrics.observe("llm_request_duration_seconds", 0.3)
metrics.gauge("active_research_runs", 3)
text = metrics.render_prometheus()
assert "search_queries_total 100" in text
assert "sources_fetched_total 50" in text
assert "claims_extracted_total 25" in text
assert "contradictions_detected_total 5" in text
assert "research_completed_total 8" in text
assert "research_failed_total 2" in text
assert "research_duration_seconds_count 1" in text
assert "llm_request_duration_seconds_count 1" in text
assert "active_research_runs 3.0" in text
def test_render_thread_safety(self) -> None:
"""render_prometheus ist thread-sicher."""
errors = []
def worker() -> None:
try:
for _ in range(100):
metrics.increment("concurrent", 1)
metrics.render_prometheus()
except Exception as e:
errors.append(str(e))
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == []
class TestConstants:
"""Test pre-defined metric name constants."""
def test_constant_values(self) -> None:
"""Konstanten sind korrekt definiert."""
from nsct.metrics import (
C_SEARCH_QUERIES_TOTAL,
C_SOURCES_FETCHED_TOTAL,
C_CLAIMS_EXTRACTED_TOTAL,
C_CONTRADICTIONS_DETECTED_TOTAL,
C_RESEARCH_COMPLETED_TOTAL,
C_RESEARCH_FAILED_TOTAL,
H_RESEARCH_DURATION,
H_LLM_REQUEST_DURATION,
G_ACTIVE_RESEARCH_RUNS,
)
assert C_SEARCH_QUERIES_TOTAL == "search_queries_total"
assert C_SOURCES_FETCHED_TOTAL == "sources_fetched_total"
assert C_CLAIMS_EXTRACTED_TOTAL == "claims_extracted_total"
assert C_CONTRADICTIONS_DETECTED_TOTAL == "contradictions_detected_total"
assert C_RESEARCH_COMPLETED_TOTAL == "research_completed_total"
assert C_RESEARCH_FAILED_TOTAL == "research_failed_total"
assert H_RESEARCH_DURATION == "research_duration_seconds"
assert H_LLM_REQUEST_DURATION == "llm_request_duration_seconds"
assert G_ACTIVE_RESEARCH_RUNS == "active_research_runs"
def test_constants_on_metrics_instance(self) -> None:
"""Konstanten sind auch auf metrics-Instanz verfügbar."""
assert metrics.C_SEARCH_QUERIES_TOTAL == "search_queries_total"
assert metrics.H_RESEARCH_DURATION == "research_duration_seconds"
class TestMetricsIntegration:
"""Integration tests — realistic pipeline usage."""
def setup_method(self) -> None:
from nsct.metrics import _counters, _histograms, _gauges, _lock
with _lock:
_counters.clear()
_histograms.clear()
_gauges.clear()
def test_full_pipeline_metrics(self) -> None:
"""Simuliert einen vollständigen Pipeline-Durchlauf."""
# Search phase
metrics.increment("search_queries_total", 5)
# Fetch phase
metrics.increment("sources_fetched_total", 3)
# Extract phase
metrics.increment("claims_extracted_total", 12)
# Contradiction detected
metrics.increment("contradictions_detected_total", 2)
# Research completed
metrics.observe("research_duration_seconds", 3.2)
metrics.increment("research_completed_total", 1)
# Active runs
metrics.gauge("active_research_runs", 0)
# Verify export
text = metrics.render_prometheus()
assert "search_queries_total 5" in text
assert "sources_fetched_total 3" in text
assert "claims_extracted_total 12" in text
assert "contradictions_detected_total 2" in text
assert "research_completed_total 1" in text
assert "active_research_runs 0.0" in text
assert "research_duration_seconds_count 1" in text
def test_no_new_dependencies(self) -> None:
"""metrics.py hat keine externen Abhängigkeiten."""
from nsct import metrics as mod
import inspect
source = inspect.getsource(mod)
assert "import prometheus" not in source
assert "from prometheus" not in source
assert "import loguru" not in source
# Should only import stdlib
assert "import threading" in source
assert "import time" in source