fischer-agentkit/tests/unit/test_telemetry.py

254 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 初始化 OTelTracerOTel 现为 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 函数内部 importpatch 源模块符号
# 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 testU1"""
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 不应被 instrumentspec 让未设属性返回 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)