"""Telemetry module unit tests.""" from unittest.mock import MagicMock, patch import pytest from agentkit.telemetry.tracer import ( NoOpSpan, NoOpTracer, OTelTracer, TelemetryConfig, get_tracer, init_telemetry, ) from agentkit.quality.alignment import AlignmentGuard, AlignmentConfig # ── NoOpSpan 测试 ────────────────────────────────────────── class TestNoOpSpan: """NoOpSpan no-op 行为测试""" def test_is_recording_returns_false(self): span = NoOpSpan() assert span.is_recording() is False def test_set_attribute_does_nothing(self): span = NoOpSpan() # 不应抛出异常 span.set_attribute("key", "value") def test_add_event_does_nothing(self): span = NoOpSpan() span.add_event("event_name", {"attr": "val"}) def test_record_exception_does_nothing(self): span = NoOpSpan() span.record_exception(ValueError("test")) def test_context_manager(self): span = NoOpSpan() with span as s: assert s is span # ── NoOpTracer 测试 ──────────────────────────────────────── class TestNoOpTracer: """NoOpTracer no-op 行为测试""" def test_start_span_returns_noop_span(self): tracer = NoOpTracer() span = tracer.start_span("test.span") assert isinstance(span, NoOpSpan) def test_start_as_current_span_returns_noop_span(self): tracer = NoOpTracer() span = tracer.start_as_current_span("test.span") assert isinstance(span, NoOpSpan) def test_start_span_with_attributes(self): tracer = NoOpTracer() span = tracer.start_span("test.span", attributes={"key": "value"}) assert isinstance(span, NoOpSpan) def test_start_span_as_context_manager(self): tracer = NoOpTracer() with tracer.start_span("test.span") as span: assert isinstance(span, NoOpSpan) # ── TelemetryConfig 测试 ─────────────────────────────────── class TestTelemetryConfig: """TelemetryConfig 默认值测试""" def test_default_values(self): config = TelemetryConfig() assert config.enabled is False assert config.otlp_endpoint == "" assert config.service_name == "agentkit" assert config.sample_rate == 1.0 def test_custom_values(self): config = TelemetryConfig( enabled=True, otlp_endpoint="http://localhost:4317", service_name="my-service", sample_rate=0.5, ) assert config.enabled is True assert config.otlp_endpoint == "http://localhost:4317" assert config.service_name == "my-service" assert config.sample_rate == 0.5 # ── init_telemetry 测试 ──────────────────────────────────── class TestInitTelemetry: """init_telemetry 初始化测试""" def test_disabled_returns_noop_tracer(self): config = TelemetryConfig(enabled=False) init_telemetry(config) tracer = get_tracer() assert isinstance(tracer, NoOpTracer) def test_enabled_initializes_otel_tracer(self): """U1: enabled=True 初始化 OTelTracer(OTel 现为 required 依赖,不再 ImportError fallback)""" config = TelemetryConfig(enabled=True, otlp_endpoint="http://localhost:4317") init_telemetry(config) tracer = get_tracer() # OTel 已安装,应返回 OTelTracer 实例(而非 NoOpTracer) assert isinstance(tracer, OTelTracer) def test_init_with_exception_falls_back_to_noop(self): """初始化过程中出现运行时异常时,降级为 NoOpTracer(保留运行时容错)""" config = TelemetryConfig(enabled=True, otlp_endpoint="http://localhost:4317") # OTLPSpanExporter 在 init_telemetry 函数内部 import,patch 源模块符号 # opentelemetry.sdk.trace.TracerProvider 是函数内 import 的真实符号 with patch( "opentelemetry.sdk.trace.TracerProvider", side_effect=RuntimeError("provider init failed"), ): init_telemetry(config) tracer = get_tracer() assert isinstance(tracer, NoOpTracer) # ── get_tracer 测试 ──────────────────────────────────────── class TestGetTracer: """get_tracer 全局实例测试""" def test_returns_global_tracer(self): # 确保先重置为 NoOpTracer init_telemetry(TelemetryConfig(enabled=False)) tracer = get_tracer() assert isinstance(tracer, NoOpTracer) def test_get_tracer_returns_same_instance(self): init_telemetry(TelemetryConfig(enabled=False)) tracer1 = get_tracer() tracer2 = get_tracer() assert tracer1 is tracer2 # ── AlignmentGuard span 测试 ────────────────────────────── class TestAlignmentGuardSpan: """AlignmentGuard 创建 span 并设置属性""" @pytest.mark.asyncio async def test_guard_creates_span_with_attributes(self): """对齐检查时创建 span 并设置 guard.passed 和 guard.checked_by 属性""" init_telemetry(TelemetryConfig(enabled=False)) tracer = get_tracer() mock_span = MagicMock() mock_span.__enter__ = MagicMock(return_value=mock_span) mock_span.__exit__ = MagicMock(return_value=False) mock_span.set_attribute = MagicMock() with patch.object(tracer, "start_span", return_value=mock_span): config = AlignmentConfig(constraints=["forbidden_word"]) guard = AlignmentGuard(config) await guard.check_output({"content": "This contains forbidden_word"}) tracer.start_span.assert_called_once_with("guard.check") call_args_list = [ (call.args[0], call.args[1]) for call in mock_span.set_attribute.call_args_list ] assert ("guard.constraints_count", 1) in call_args_list assert ("guard.passed", False) in call_args_list assert ("guard.checked_by", "rule") in call_args_list @pytest.mark.asyncio async def test_guard_span_on_pass(self): """对齐检查通过时 span 设置 guard.passed=True""" init_telemetry(TelemetryConfig(enabled=False)) tracer = get_tracer() mock_span = MagicMock() mock_span.__enter__ = MagicMock(return_value=mock_span) mock_span.__exit__ = MagicMock(return_value=False) mock_span.set_attribute = MagicMock() with patch.object(tracer, "start_span", return_value=mock_span): config = AlignmentConfig(constraints=["forbidden_word"]) guard = AlignmentGuard(config) await guard.check_output({"content": "This is clean text"}) call_args_list = [ (call.args[0], call.args[1]) for call in mock_span.set_attribute.call_args_list ] assert ("guard.passed", True) in call_args_list assert ("guard.checked_by", "rule") in call_args_list # ── setup_telemetry smoke test ─────────────────────────── class TestSetupTelemetry: """setup_telemetry FastAPI 装配 smoke test(U1)""" def test_disabled_is_noop(self): """enabled=False 时不做任何装配""" from fastapi import FastAPI from agentkit.telemetry.setup import setup_telemetry # spec=FastAPI 让 getattr 返回真实的默认值(而非 MagicMock 自动属性) app = MagicMock(spec=FastAPI) setup_telemetry(app, {"enabled": False}) # app 不应被 instrument(spec 让未设属性返回 AttributeError→default False) assert not getattr(app, "_is_instrumented_by_opentelemetry", False) def test_enabled_instruments_fastapi(self): """enabled=True 时 FastAPIInstrumentor 装配 app(不崩溃即可)""" from fastapi import FastAPI from agentkit.telemetry.setup import setup_telemetry app = FastAPI() # 使用一个无效 endpoint — exporter 会失败但不阻塞 instrument setup_telemetry( app, { "enabled": True, "otlp_endpoint": "http://127.0.0.1:4317", "service_name": "test-agentkit", "export_traces": True, "export_metrics": True, }, ) # FastAPIInstrumentor 装配后会设置此属性 assert getattr(app, "_is_instrumented_by_opentelemetry", False) is True def test_none_config_is_noop(self): """config=None 不崩溃""" from fastapi import FastAPI from agentkit.telemetry.setup import setup_telemetry app = MagicMock(spec=FastAPI) setup_telemetry(app, None) assert not getattr(app, "_is_instrumented_by_opentelemetry", False)