diff --git a/agentkit.yaml b/agentkit.yaml index ef3f66f..62c1411 100644 --- a/agentkit.yaml +++ b/agentkit.yaml @@ -70,19 +70,25 @@ fallback_chain: # Tests run in-memory for isolation; production / staging should use Redis. session: backend: ${AGENTKIT_SESSION_BACKEND:-memory} + redis_url: ${REDIS_URL:-redis://127.0.0.1:6391/0} bus: backend: ${AGENTKIT_BUS_BACKEND:-memory} - redis_url: ${REDIS_URL:-redis://127.0.0.1:6379/0} + redis_url: ${REDIS_URL:-redis://127.0.0.1:6391/0} task_store: backend: ${AGENTKIT_TASK_STORE_BACKEND:-memory} + redis_url: ${REDIS_URL:-redis://127.0.0.1:6391/0} skills: {auto_discover: true, paths: ["./configs/skills"]} experts: {paths: ["./configs/experts"]} board: {max_rounds: 5, default_template: private_board, parallel_speech: true, history_compression_threshold: 20} logging: {level: INFO, format: text} router: {classifier: heuristic, auction_enabled: false} -# OTel 可观测性 — 默认注释(OTel 为可选依赖,未安装时 telemetry/metrics.py 返回 NoOp)。 -# 启用:pip install opentelemetry-sdk opentelemetry-exporter-otlp,取消注释并指向 collector。 -# 未配置时所有指标(请求量/延迟/token 消耗)静默丢弃,形成监控盲区。 -# telemetry: -# otlp_endpoint: http://localhost:4317 # OTLP gRPC 端点 -# service_name: fischer-agentkit +# OTel 可观测性(U1 — 默认启用)。 +# OTel 依赖已升级为 required(pyproject.toml [project] dependencies), +# ImportError 静默回退已移除 — 缺包将硬失败。设 enabled=false 可显式关闭。 +# otlp_endpoint 指向 OTel collector(容器名 otel-collector / jaeger)。 +telemetry: + enabled: true + otlp_endpoint: http://localhost:4317 # OTLP gRPC 端点(开发环境;生产指向集群 collector) + service_name: fischer-agentkit + export_traces: true + export_metrics: true diff --git a/pyproject.toml b/pyproject.toml index e713435..d53f45f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,11 @@ dependencies = [ # 异步任务队列(U8 — 文档向量化) "taskiq>=0.11", "taskiq-redis>=0.5", + # OTel 可观测性(U1 — 默认启用,从 optional 升级为 required,移除 ImportError 静默回退) + "opentelemetry-api>=1.24", + "opentelemetry-sdk>=1.24", + "opentelemetry-exporter-otlp-proto-grpc>=1.24", + "opentelemetry-instrumentation-fastapi>=0.45b0", ] [project.scripts] diff --git a/src/agentkit/telemetry/metrics.py b/src/agentkit/telemetry/metrics.py index 0525be7..5c4303c 100644 --- a/src/agentkit/telemetry/metrics.py +++ b/src/agentkit/telemetry/metrics.py @@ -1,11 +1,15 @@ -"""Metric definitions — no-op when OTel not installed""" +"""Metric definitions — OTel now required (U1). -try: - from opentelemetry import metrics +_OTEL_AVAILABLE kept as a constant True for backward compatibility with +existing call sites (react.py / rewoo.py / reflexion.py / gateway.py) that +guard span creation with `if _OTEL_AVAILABLE:`. The guard is now redundant +but kept to avoid churn; tests still patch it to False to exercise the +no-span path. +""" - _OTEL_AVAILABLE = True -except ImportError: - _OTEL_AVAILABLE = False +from opentelemetry import metrics + +_OTEL_AVAILABLE = True class _NoOpCounter: @@ -92,9 +96,7 @@ def tool_duration_histogram(): """Tool call duration.""" global _tool_duration_histogram if _tool_duration_histogram is None: - _tool_duration_histogram = _get_histogram( - "tool.call.duration", "Tool call duration" - ) + _tool_duration_histogram = _get_histogram("tool.call.duration", "Tool call duration") return _tool_duration_histogram diff --git a/src/agentkit/telemetry/setup.py b/src/agentkit/telemetry/setup.py index 5da9581..38b01d6 100644 --- a/src/agentkit/telemetry/setup.py +++ b/src/agentkit/telemetry/setup.py @@ -1,16 +1,26 @@ -"""OTel initialization — called at app startup""" +"""OTel initialization — called at app startup (U1: OTel now required).""" import logging +from opentelemetry import trace, metrics +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + logger = logging.getLogger(__name__) def setup_telemetry(app, config: dict | None = None): - """Initialize OpenTelemetry if installed and configured. + """Initialize OpenTelemetry if configured. - This is a no-op when: - - config is None or config.enabled is False - - opentelemetry packages are not installed + U1: opentelemetry-* deps now required (pyproject.toml). ImportError + fallbacks removed — a missing package is a hard install error. The + `enabled=False` config switch remains the supported way to disable. Args: app: FastAPI application instance @@ -25,69 +35,29 @@ def setup_telemetry(app, config: dict | None = None): logger.info("Telemetry disabled") return - try: - from opentelemetry import trace, metrics - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader - from opentelemetry.sdk.resources import Resource - except ImportError: - logger.warning( - "OpenTelemetry packages not installed. Telemetry disabled." - ) - return - service_name = config.get("service_name", "fischer-agentkit") resource = Resource.create({"service.name": service_name}) # Tracing setup if config.get("export_traces", True): endpoint = config.get("otlp_endpoint", "http://localhost:4317") - try: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( - OTLPSpanExporter, - ) - - provider = TracerProvider(resource=resource) - provider.add_span_processor( - BatchSpanProcessor( - OTLPSpanExporter(endpoint=endpoint, insecure=True) - ) - ) - trace.set_tracer_provider(provider) - logger.info(f"Tracing enabled, exporting to {endpoint}") - except ImportError: - logger.warning( - "OTLP exporter not installed. Tracing disabled." - ) + provider = TracerProvider(resource=resource) + provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=True)) + ) + trace.set_tracer_provider(provider) + logger.info(f"Tracing enabled, exporting to {endpoint}") # Metrics setup if config.get("export_metrics", True): endpoint = config.get("otlp_endpoint", "http://localhost:4317") - try: - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( - OTLPMetricExporter, - ) - - reader = PeriodicExportingMetricReader( - OTLPMetricExporter(endpoint=endpoint, insecure=True) - ) - provider = MeterProvider(resource=resource, readers=[reader]) - metrics.set_meter_provider(provider) - logger.info(f"Metrics enabled, exporting to {endpoint}") - except ImportError: - logger.warning( - "OTLP metric exporter not installed. Metrics disabled." - ) + reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint=endpoint, insecure=True)) + # ponytail: OTel SDK 1.16+ 将 readers kwarg 改名为 metric_readers; + # 旧代码用 readers=[...] 是已存在 bug,被 ImportError fallback 掩盖。 + provider = MeterProvider(resource=resource, metric_readers=[reader]) + metrics.set_meter_provider(provider) + logger.info(f"Metrics enabled, exporting to {endpoint}") # FastAPI auto-instrumentation - try: - from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor - - FastAPIInstrumentor.instrument_app(app, excluded_urls="health,metrics") - logger.info("FastAPI auto-instrumentation enabled") - except ImportError: - logger.warning( - "FastAPI instrumentation not installed. Skipping auto-instrumentation." - ) + FastAPIInstrumentor.instrument_app(app, excluded_urls="health,metrics") + logger.info("FastAPI auto-instrumentation enabled") diff --git a/src/agentkit/telemetry/tracer.py b/src/agentkit/telemetry/tracer.py index ad808ef..7893bad 100644 --- a/src/agentkit/telemetry/tracer.py +++ b/src/agentkit/telemetry/tracer.py @@ -1,4 +1,5 @@ """OpenTelemetry tracer integration with no-op fallback.""" + from __future__ import annotations import logging @@ -43,10 +44,14 @@ class NoOpSpan: class NoOpTracer: """No-op tracer when telemetry is disabled.""" - def start_span(self, name: str, attributes: dict[str, object] | None = None) -> ContextManager[NoOpSpan]: + def start_span( + self, name: str, attributes: dict[str, object] | None = None + ) -> ContextManager[NoOpSpan]: return NoOpSpan() - def start_as_current_span(self, name: str, attributes: dict[str, object] | None = None) -> ContextManager[NoOpSpan]: + def start_as_current_span( + self, name: str, attributes: dict[str, object] | None = None + ) -> ContextManager[NoOpSpan]: return NoOpSpan() @@ -86,7 +91,9 @@ class OTelTracer: span = self._tracer.start_span(name, attributes=attributes) return OTelSpan(span) - def start_as_current_span(self, name: str, attributes: dict[str, object] | None = None) -> OTelSpan: + def start_as_current_span( + self, name: str, attributes: dict[str, object] | None = None + ) -> OTelSpan: span = self._tracer.start_as_current_span(name, attributes=attributes) return OTelSpan(span) @@ -101,7 +108,13 @@ def get_tracer() -> NoOpTracer | OTelTracer: def init_telemetry(config: TelemetryConfig) -> None: - """Initialize telemetry with the given configuration.""" + """Initialize telemetry with the given configuration. + + U1: OTel deps now required (pyproject.toml). ImportError fallback removed — + a missing opentelemetry-* package is now a hard install error, not a silent + monitoring blind spot. Runtime errors (network, exporter connect) still + fall back to NoOpTracer so app startup never crashes on transient OTLP issues. + """ global _tracer if not config.enabled: @@ -109,13 +122,13 @@ def init_telemetry(config: TelemetryConfig) -> None: logger.info("Telemetry disabled, using no-op tracer") return - try: - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - from opentelemetry.sdk.resources import Resource - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.sdk.resources import Resource + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + try: resource = Resource.create({"service.name": config.service_name}) provider = TracerProvider(resource=resource) @@ -126,9 +139,6 @@ def init_telemetry(config: TelemetryConfig) -> None: trace.set_tracer_provider(provider) _tracer = OTelTracer(trace.get_tracer(config.service_name)) logger.info(f"Telemetry initialized with endpoint: {config.otlp_endpoint}") - except ImportError: - logger.warning("opentelemetry packages not installed, using no-op tracer") - _tracer = NoOpTracer() except Exception as e: logger.warning(f"Failed to initialize telemetry: {e}, using no-op tracer") _tracer = NoOpTracer() diff --git a/src/agentkit/telemetry/tracing.py b/src/agentkit/telemetry/tracing.py index 4f3c65c..7b10224 100644 --- a/src/agentkit/telemetry/tracing.py +++ b/src/agentkit/telemetry/tracing.py @@ -1,43 +1,33 @@ -"""Tracing helpers — no-op when OTel not installed""" +"""Tracing helpers — OTel now required (U1). + +_OTEL_AVAILABLE kept as a constant True for backward compatibility with +existing call sites (react.py / rewoo.py / reflexion.py / gateway.py) that +guard span creation with `if _OTEL_AVAILABLE:`. The guard is now redundant +but kept to avoid churn; tests still patch it to False to exercise the +no-span path. +""" import logging import time from functools import wraps from typing import Callable +from opentelemetry import trace +from opentelemetry.trace import SpanKind, Status, StatusCode + logger = logging.getLogger(__name__) -# Try importing OTel — if not available, provide no-op implementations -try: - from opentelemetry import trace - from opentelemetry.trace import SpanKind, Status, StatusCode - - _OTEL_AVAILABLE = True -except ImportError: - _OTEL_AVAILABLE = False - - # Provide fallback stubs so module-level references work in tests - class _StubEnum: - INTERNAL = "INTERNAL" - CLIENT = "CLIENT" - SERVER = "SERVER" - PRODUCER = "PRODUCER" - CONSUMER = "CONSUMER" - - SpanKind = _StubEnum # type: ignore[misc,assignment] - - class Status: # type: ignore[no-redef] - def __init__(self, *args, **kwargs): - pass - - class StatusCode: # type: ignore[no-redef] - UNSET = "UNSET" - OK = "OK" - ERROR = "ERROR" +_OTEL_AVAILABLE = True class _NoOpSpan: - """No-op span context manager used when OTel is not installed.""" + """No-op span context manager — retained for tests that patch + `_OTEL_AVAILABLE = False` and call `start_span` directly. + + ponytail: kept only for test compatibility; production code now always + has OTel installed. Safe to remove once tests are migrated to patch + `opentelemetry.trace` directly. + """ def __enter__(self): return self @@ -59,10 +49,8 @@ class _NoOpSpan: def get_tracer(name: str = "fischer.agentkit"): - """Get tracer — returns None if OTel not installed.""" - if _OTEL_AVAILABLE: - return trace.get_tracer(name) - return None + """Get tracer — returns the global OTel tracer (always available post-U1).""" + return trace.get_tracer(name) def start_span( @@ -70,9 +58,9 @@ def start_span( kind: object = None, attributes: dict | None = None, ): - """Start a span — returns no-op span if OTel not installed. + """Start a span. Returns an OTel span context manager. - Returns a context manager that yields a span (or no-op). + `_OTEL_AVAILABLE` guard retained for tests that patch it to False. """ if not _OTEL_AVAILABLE: return _NoOpSpan() diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index b3bfc66..af0e866 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -1,13 +1,12 @@ """Telemetry module unit tests.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from agentkit.telemetry.tracer import ( NoOpSpan, NoOpTracer, - OTelSpan, OTelTracer, TelemetryConfig, get_tracer, @@ -110,29 +109,26 @@ class TestInitTelemetry: tracer = get_tracer() assert isinstance(tracer, NoOpTracer) - def test_missing_opentelemetry_falls_back_to_noop(self): - """当 opentelemetry 包未安装时,优雅降级为 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") - # 即使 opentelemetry 未安装,也不应抛出异常 init_telemetry(config) tracer = get_tracer() - # 在没有 opentelemetry 的环境中应为 NoOpTracer - assert isinstance(tracer, (NoOpTracer, OTelTracer)) + # OTel 已安装,应返回 OTelTracer 实例(而非 NoOpTracer) + assert isinstance(tracer, OTelTracer) def test_init_with_exception_falls_back_to_noop(self): - """初始化过程中出现异常时,降级为 NoOpTracer""" + """初始化过程中出现运行时异常时,降级为 NoOpTracer(保留运行时容错)""" config = TelemetryConfig(enabled=True, otlp_endpoint="http://localhost:4317") + # OTLPSpanExporter 在 init_telemetry 函数内部 import,patch 源模块符号 + # opentelemetry.sdk.trace.TracerProvider 是函数内 import 的真实符号 with patch( - "agentkit.telemetry.tracer.init_telemetry", - side_effect=Exception("init error"), - ) as mock_init: - # init_telemetry 被模拟为抛异常,但实际调用的是原始函数 - # 这里验证的是 init_telemetry 本身不会崩溃 - pass - # 直接调用,不应崩溃 - init_telemetry(config) - tracer = get_tracer() - assert isinstance(tracer, (NoOpTracer, OTelTracer)) + "opentelemetry.sdk.trace.TracerProvider", + side_effect=RuntimeError("provider init failed"), + ): + init_telemetry(config) + tracer = get_tracer() + assert isinstance(tracer, NoOpTracer) # ── get_tracer 测试 ──────────────────────────────────────── @@ -174,14 +170,11 @@ class TestAlignmentGuardSpan: with patch.object(tracer, "start_span", return_value=mock_span): config = AlignmentConfig(constraints=["forbidden_word"]) guard = AlignmentGuard(config) - result = await guard.check_output( - {"content": "This contains forbidden_word"} - ) + 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 + (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 @@ -201,13 +194,60 @@ class TestAlignmentGuardSpan: with patch.object(tracer, "start_span", return_value=mock_span): config = AlignmentConfig(constraints=["forbidden_word"]) guard = AlignmentGuard(config) - result = await guard.check_output( - {"content": "This is clean text"} - ) + 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 + (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)