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
This commit is contained in:
NSCT Agent
2026-08-27 19:44:46 +00:00
parent b2340fca63
commit 08ec0e290c
7 changed files with 1088 additions and 60 deletions

View File

@@ -18,6 +18,16 @@ from uuid import UUID, uuid4
from nsct.agents.planner import MockResearchPlanner, ResearchPlanner
from nsct.config import AppSettings
from nsct.crawler.manager import CrawlerManager
from nsct.logging_config import set_research_run_id, get_research_run_id, get_logger
from nsct.metrics import (
metrics,
C_SEARCH_QUERIES_TOTAL,
C_SOURCES_FETCHED_TOTAL,
C_CLAIMS_EXTRACTED_TOTAL,
C_CONTRADICTIONS_DETECTED_TOTAL,
H_RESEARCH_DURATION,
H_LLM_REQUEST_DURATION,
)
from nsct.models.claim import Claim
from nsct.orchestration.budget import BudgetExhaustedError, BudgetTracker, HardBudgetConfig
from nsct.orchestration.models import ResearchRun
@@ -312,6 +322,9 @@ class ResearchOrchestrator:
if self._run is None:
await self.start()
# Record research start timestamp for duration histogram
_start = time.monotonic()
# Pipeline-Schritte nacheinander
steps = [
"planning",
@@ -383,7 +396,11 @@ class ResearchOrchestrator:
"budget_usage": self._budget_tracker.get_usage(),
}
logger.info("Research pipeline completed: %d claims, state=%s", len(self._claims), report["state"])
elapsed = time.monotonic() - _start
metrics.observe(H_RESEARCH_DURATION, elapsed)
logger.info("Research pipeline completed: %d claims, state=%s, %.1fs",
len(self._claims), report["state"], elapsed)
return report
async def run_step(self, step_name: str) -> dict[str, Any]:
@@ -428,6 +445,7 @@ class ResearchOrchestrator:
"""PLANNING: Erstelle Recherchestrategie mit dem Planner."""
self._transition_to("planning")
llm_duration_start = time.monotonic()
try:
planner = self._get_planner()
if isinstance(planner, MockResearchPlanner):
@@ -436,6 +454,9 @@ class ResearchOrchestrator:
else:
self._budget_tracker.increment_llm_requests(1)
plan = await planner.plan(self._query, language="de")
# Track LLM request duration
elapsed = time.monotonic() - llm_duration_start
metrics.observe(H_LLM_REQUEST_DURATION, elapsed)
self._plan = plan
@@ -514,6 +535,7 @@ class ResearchOrchestrator:
self._search_results.append(result_dict)
logger.info("Search complete: %d unique URLs collected", len(self._search_results))
metrics.increment(C_SEARCH_QUERIES_TOTAL, len(plan_queries))
return {"success": True, "data": {"urls": self._search_results}, "url_count": len(self._search_results)}
except Exception as exc:
@@ -561,6 +583,7 @@ class ResearchOrchestrator:
self._budget_tracker.increment_download_bytes(content_len)
successful = [s for s in self._sources if not s.get("error")]
metrics.increment(C_SOURCES_FETCHED_TOTAL, len(self._sources))
logger.info("Fetching complete: %d/%d sources successfully fetched", len(successful), len(self._sources))
return {
"success": True,
@@ -614,6 +637,7 @@ class ResearchOrchestrator:
)
logger.info("Extraction complete: %d claims", len(self._claims))
metrics.increment(C_CLAIMS_EXTRACTED_TOTAL, len(self._claims))
return {"success": True, "data": {"claims": [c.model_dump() for c in self._claims]}, "claim_count": len(self._claims)}
except Exception as exc: