306 lines
10 KiB
Python
306 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["skills"]["total_skills"] == 0
|
|
|
|
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
|
|
|
|
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"
|