feat(telemetry): U1 — OTel 默认启用,从 optional 升级为 required 依赖

- pyproject.toml: 添加 opentelemetry-api/sdk/exporter-otlp-proto-grpc/
  instrumentation-fastapi 到 [project] dependencies(KTD-3)
- telemetry/{tracer,metrics,tracing,setup}.py: 移除 try/except ImportError
  静默回退 — 缺包现在是硬安装错误,不再是监控盲区。保留 except Exception
  处理运行时错误(OTLP 连接失败),保留 enabled=false 配置开关
- setup.py: 修复已存在 bug — MeterProvider 的 readers kwarg 在 OTel SDK 1.16+
  改名为 metric_readers(被 ImportError fallback 掩盖)
- agentkit.yaml: 取消 telemetry 段注释,设 enabled=true + otlp_endpoint +
  service_name + export_traces/metrics
- test_telemetry.py: 更新 test_missing_opentelemetry → test_enabled_initializes_otel_tracer;
  新增 TestSetupTelemetry smoke test(disabled/none 是 no-op,enabled instrument FastAPI);
  清理 unused imports/vars

Verification:
- pytest tests/unit/test_telemetry.py: 21 passed
- pytest tests/unit/test_react_compression.py: 22 passed(_OTEL_AVAILABLE patch 兼容)
- runtime check: enabled=True → OTelTracer, enabled=False → NoOpTracer
This commit is contained in:
Chiguyong 2026-07-06 00:18:09 +08:00
parent 8633f60831
commit d998c0344c
7 changed files with 172 additions and 151 deletions

View File

@ -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 依赖已升级为 requiredpyproject.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

View File

@ -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]

View File

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

View File

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

View File

@ -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()

View File

@ -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()

View File

@ -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 初始化 OTelTracerOTel 现为 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 函数内部 importpatch 源模块符号
# 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 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)