接入 OTel 链路追踪 + 镜像默认启用
抓取-解析链路原本只有日志,出现"抓回内容但解析不出预期字段"时定位慢。 接入 OpenTelemetry traces(FastAPI/httpx 自动 + 手写 fetch/parse span), 解析失败时把页面 HTML 作为 span event 上报,便于事后复现。 Dockerfile 默认开启,镜像一启动即导出到自建 OTLP endpoint。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user