"""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"