fischer-agentkit/tests/unit/test_observability.py

309 lines
10 KiB
Python

"""Unit tests for observability features: structured logging, metrics, health check"""
import json
import logging
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, MagicMock
from agentkit.core.logging import StructuredFormatter, setup_structured_logging, get_logger
from agentkit.core.protocol import TaskStatus
from agentkit.llm.gateway import LLMGateway
from agentkit.llm.protocol import LLMResponse, TokenUsage
from agentkit.server.app import create_app
from agentkit.skills.base import Skill, SkillConfig
from agentkit.skills.registry import SkillRegistry
from agentkit.tools.registry import ToolRegistry
# ── Structured Logging Tests ────────────────────────────────────────
class TestStructuredFormatter:
"""StructuredFormatter outputs valid JSON with required fields"""
def test_outputs_valid_json(self):
formatter = StructuredFormatter()
record = logging.LogRecord(
name="agentkit.test",
level=logging.INFO,
pathname="test.py",
lineno=1,
msg="hello world",
args=(),
exc_info=None,
)
output = formatter.format(record)
data = json.loads(output)
assert "timestamp" in data
assert data["level"] == "INFO"
assert data["logger"] == "agentkit.test"
assert data["message"] == "hello world"
def test_includes_extra_fields(self):
formatter = StructuredFormatter()
record = logging.LogRecord(
name="agentkit.test",
level=logging.INFO,
pathname="test.py",
lineno=1,
msg="with extras",
args=(),
exc_info=None,
)
record.trace_id = "abc-123"
record.agent_name = "my_agent"
record.skill_name = "my_skill"
record.task_id = "task-456"
output = formatter.format(record)
data = json.loads(output)
assert data["trace_id"] == "abc-123"
assert data["agent_name"] == "my_agent"
assert data["skill_name"] == "my_skill"
assert data["task_id"] == "task-456"
def test_omits_empty_extra_fields(self):
formatter = StructuredFormatter()
record = logging.LogRecord(
name="agentkit.test",
level=logging.INFO,
pathname="test.py",
lineno=1,
msg="no extras",
args=(),
exc_info=None,
)
output = formatter.format(record)
data = json.loads(output)
assert "trace_id" not in data
assert "agent_name" not in data
def test_includes_exception_info(self):
formatter = StructuredFormatter()
try:
raise ValueError("test error")
except ValueError:
import sys
exc_info = sys.exc_info()
record = logging.LogRecord(
name="agentkit.test",
level=logging.ERROR,
pathname="test.py",
lineno=1,
msg="error occurred",
args=(),
exc_info=exc_info,
)
output = formatter.format(record)
data = json.loads(output)
assert "exception" in data
assert "ValueError" in data["exception"]
assert "test error" in data["exception"]
def test_unicode_message(self):
formatter = StructuredFormatter()
record = logging.LogRecord(
name="agentkit.test",
level=logging.INFO,
pathname="test.py",
lineno=1,
msg="中文日志消息",
args=(),
exc_info=None,
)
output = formatter.format(record)
data = json.loads(output)
assert data["message"] == "中文日志消息"
class TestSetupStructuredLogging:
"""setup_structured_logging() configures agentkit logger"""
def test_configures_agentkit_logger(self):
setup_structured_logging(level=logging.DEBUG)
logger = logging.getLogger("agentkit")
assert logger.level == logging.DEBUG
assert len(logger.handlers) == 1
handler = logger.handlers[0]
assert isinstance(handler.formatter, StructuredFormatter)
def test_clears_existing_handlers(self):
logger = logging.getLogger("agentkit")
logger.addHandler(logging.StreamHandler())
initial_count = len(logger.handlers)
setup_structured_logging()
assert len(logger.handlers) == 1
assert len(logger.handlers) < initial_count + 1
class TestGetLogger:
"""get_logger() creates logger with extra fields"""
def test_returns_logger_adapter(self):
adapter = get_logger("my_module")
assert isinstance(adapter, logging.LoggerAdapter)
assert adapter.logger.name == "agentkit.my_module"
def test_extra_fields_in_adapter(self):
adapter = get_logger("test", trace_id="t-1", agent_name="a-1")
assert adapter.extra["trace_id"] == "t-1"
assert adapter.extra["agent_name"] == "a-1"
# ── Metrics Endpoint Tests ─────────────────────────────────────────
@pytest.fixture
def mock_llm_gateway():
gateway = LLMGateway()
mock_provider = AsyncMock()
mock_provider.chat.return_value = LLMResponse(
content='{"result": "mocked"}',
model="test-model",
usage=TokenUsage(prompt_tokens=10, completion_tokens=20),
)
gateway.register_provider("test", mock_provider)
return gateway
@pytest.fixture
def skill_registry():
return SkillRegistry()
@pytest.fixture
def tool_registry():
return ToolRegistry()
@pytest.fixture
def app(mock_llm_gateway, skill_registry, tool_registry):
return create_app(
llm_gateway=mock_llm_gateway,
skill_registry=skill_registry,
tool_registry=tool_registry,
)
@pytest.fixture
def client(app):
return TestClient(app)
class TestMetricsEndpoint:
"""GET /api/v1/metrics"""
def test_metrics_returns_200(self, client):
response = client.get("/api/v1/metrics")
assert response.status_code == 200
def test_metrics_has_required_sections(self, client):
response = client.get("/api/v1/metrics")
data = response.json()
assert "tasks" in data
assert "agents" in data
assert "skills" in data
assert "version" in data
def test_metrics_zero_values_when_empty(self, client):
response = client.get("/api/v1/metrics")
data = response.json()
assert data["tasks"]["total_tasks"] == 0
assert data["tasks"]["completed_tasks"] == 0
assert data["tasks"]["failed_tasks"] == 0
assert data["tasks"]["pending_tasks"] == 0
assert data["agents"]["total_agents"] == 0
assert data["agents"]["agent_names"] == []
assert data["skills"]["total_skills"] == 0
assert data["skills"]["skill_names"] == []
def test_metrics_with_registered_skill(self, client, skill_registry):
skill_config = SkillConfig(
name="metrics_skill",
agent_type="test_type",
task_mode="llm_generate",
prompt={"identity": "Metrics Skill"},
intent={"keywords": ["metrics"], "description": "A metrics skill"},
)
skill = Skill(config=skill_config)
skill_registry.register(skill)
response = client.get("/api/v1/metrics")
data = response.json()
assert data["skills"]["total_skills"] == 1
assert "metrics_skill" in data["skills"]["skill_names"]
def test_metrics_version(self, client):
response = client.get("/api/v1/metrics")
data = response.json()
assert data["version"] == "2.0.0"
# ── Enhanced Health Check Tests ─────────────────────────────────────
class TestEnhancedHealthCheck:
"""GET /api/v1/health — enhanced with dependency checks"""
def test_health_returns_200(self, client):
response = client.get("/api/v1/health")
assert response.status_code == 200
def test_health_includes_checks(self, client):
response = client.get("/api/v1/health")
data = response.json()
assert "checks" in data
assert "redis" in data["checks"]
assert "agent_pool" in data["checks"]
assert "llm_gateway" in data["checks"]
assert "skill_registry" in data["checks"]
def test_health_healthy_with_provider(self, client):
"""With a registered LLM provider, status should be healthy"""
response = client.get("/api/v1/health")
data = response.json()
assert data["status"] == "healthy"
assert data["version"] == "2.0.0"
def test_health_agent_pool_info(self, client):
response = client.get("/api/v1/health")
data = response.json()
pool_check = data["checks"]["agent_pool"]
assert pool_check["status"] == "available"
assert pool_check["size"] == 0
def test_health_skill_registry_info(self, client):
response = client.get("/api/v1/health")
data = response.json()
registry_check = data["checks"]["skill_registry"]
assert registry_check["status"] == "available"
assert registry_check["count"] == 0
def test_health_degraded_without_providers(self, skill_registry, tool_registry):
"""Without LLM providers, status should be degraded"""
gateway = LLMGateway() # No providers registered
app = create_app(
llm_gateway=gateway,
skill_registry=skill_registry,
tool_registry=tool_registry,
)
client = TestClient(app)
response = client.get("/api/v1/health")
data = response.json()
assert data["status"] == "degraded"
assert data["checks"]["llm_gateway"] == "no_providers"
def test_health_redis_not_configured_for_memory_store(self, client):
"""In-memory task store should report redis as not_configured"""
response = client.get("/api/v1/health")
data = response.json()
assert data["checks"]["redis"] == "not_configured"
def test_health_llm_gateway_available_with_provider(self, client):
response = client.get("/api/v1/health")
data = response.json()
assert data["checks"]["llm_gateway"] == "available"