"""OpenTelemetry traces 接入:仅在 otel_enabled=true 时初始化,否则全 noop 抓取与交易两个进程在 lifespan 启动时各自调用 `setup_telemetry(settings, service_name=...)`:注册 TracerProvider + OTLP/HTTP exporter + 自动 instrumentation(FastAPI、httpx)。失败时(如 endpoint 不可达)不阻断主流程, 仅打日志;traces 是辅助观测,不应让进程起不来。 `shutdown_telemetry` 在 lifespan 关闭时 force_flush 后再 shutdown,确保缓冲区 里的 span 都已上报。 OTel SDK 默认的 ProxyTracerProvider 在 setup 之前就能用(noop span),所以 其它代码里直接 `trace.get_tracer(__name__)` + `start_as_current_span` 即可, 不必关心 telemetry 是否启用——禁用时 span 不会真正产生与上报。 """ from __future__ import annotations import logging from typing import TYPE_CHECKING from opentelemetry import trace from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.sampling import ALWAYS_ON from opentelemetry.trace import Span from opentelemetry.util.types import AttributeValue from app.shared.config import Settings if TYPE_CHECKING: from fastapi import FastAPI logger = logging.getLogger(__name__) # 全局 provider 引用,用于 instrument_app / shutdown 时判断当前是否已初始化。 # 显式持有比依赖 trace.get_tracer_provider() 的类型判断更稳——后者在测试场景 # 下可能被其它用例改动全局状态。 _provider: TracerProvider | None = None def setup_telemetry(settings: Settings, *, service_name: str) -> None: """初始化 OTel:TracerProvider + OTLP exporter + httpx 自动 instrumentation。 - `otel_enabled=False` 或 endpoint 未配时仅打日志,不做任何事。 - 必须在创建任何 httpx.AsyncClient 之前调用,否则 httpx 不会被打桩。 两侧 main.py 的 lifespan 已把 setup 放在 site_session.start() 之前。 - 重复调用安全(_provider 已设时直接返回)。 """ global _provider if _provider is not None: return if not settings.otel_enabled or not settings.otel_endpoint: logger.info("OpenTelemetry 未启用(endpoint 或 otel_enabled 未配置)") return resource = Resource.create({SERVICE_NAME: service_name}) provider = TracerProvider(resource=resource, sampler=ALWAYS_ON) exporter = OTLPSpanExporter( endpoint=settings.otel_endpoint, headers=_parse_headers(settings.otel_headers), timeout=10, ) provider.add_span_processor( BatchSpanProcessor( exporter, schedule_delay_millis=settings.otel_export_interval_ms, ) ) trace.set_tracer_provider(provider) _provider = provider # httpx 是抓取/交易两侧唯一的外部 HTTP 客户端,打桩后所有 AsyncClient 请求 # 自动产生 CLIENT span。失败不影响主链路:进程仍可运行,只是看不到 span。 try: HTTPXClientInstrumentor().instrument() except Exception: logger.warning("httpx 自动 instrumentation 失败", exc_info=True) logger.info( "OpenTelemetry 已启用:endpoint=%s service=%s", settings.otel_endpoint, service_name, ) def instrument_app(app: "FastAPI") -> None: """FastAPI 应用打桩;未初始化时 noop,调用顺序无要求。""" if _provider is None: return FastAPIInstrumentor.instrument_app(app) def shutdown_telemetry() -> None: """flush + shutdown;幂等,未初始化时直接返回。""" global _provider if _provider is None: return try: _provider.force_flush() _provider.shutdown() except Exception: logger.debug("关闭 OpenTelemetry provider 失败", exc_info=True) _provider = None def snapshot(span: Span, name: str, html: str | None, max_bytes: int) -> None: """把 HTML 作为 span event 上报,超 max_bytes 截断并标注。 用于解析失败时复现页面:span 自身只放结构化指标(items 数、source 等), 完整 HTML 体量大、含商品/价格内容,仅在失败分支通过 event 携带。 """ if html is None or not html: return original_bytes = len(html) truncated = original_bytes > max_bytes payload = html if not truncated else html[:max_bytes] attributes: dict[str, AttributeValue] = { "snapshot.html": payload, "snapshot.original_bytes": original_bytes, } if truncated: attributes["snapshot.truncated"] = True span.add_event(name, attributes=attributes) def _parse_headers(raw: str | None) -> list[tuple[str, str]] | None: """解析 "k1=v1,k2=v2" 形式的 header 配置;空输入返回 None。""" if not raw or not raw.strip(): return None pairs: list[tuple[str, str]] = [] for chunk in raw.split(","): key, sep, value = chunk.partition("=") if not sep or not key.strip(): continue pairs.append((key.strip(), value.strip())) return pairs or None def is_initialized() -> bool: """供测试断言使用。""" return _provider is not None