02 - 埋点的四种做法
前置:01 篇的 span 结构与操作类型。
本篇回答:这些 span 具体由谁产生。四条路线的改动面从"每个调用点加两行"到"一行代码都不动",但改动面越小,能看见的东西也越少 —— 这个取舍怎么算。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| instrumentor | 埋点器。一个负责"把某个库的调用变成 span"的组件,比如专门管 openai 这个包的那个 |
| 猴补丁(monkey patch) | 运行时把一个已经存在的函数替换成自己的版本,原函数在新版本里被调用。埋点库靠它在不改你代码的前提下插入计时和属性记录 |
| entry point | Python 包在安装时向系统声明"我提供了某类插件"的机制。写在 pyproject.toml 里,安装后可被其他程序按组名枚举出来 |
| 字节码注入 | Java 侧的等价手段。JVM 启动时由 agent 拦截类加载,在方法体前后织入计时代码 |
| uprobe | Linux 内核提供的用户态函数断点。挂上去之后,目标进程每次调用该函数都会触发一段 eBPF 程序,目标进程本身无感 |
| 旁路 | 采集器不在业务进程里,而是从外部观察它 —— 内核探针或网络代理都属于旁路 |
一、四条路线
按"要改多少东西"排开,四条路线是一条连续的谱系:
下面逐条拆。
二、做法 A:手工埋点
最直白的一种,OpenTelemetry SDK 原生 API:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def summarize_sales(question: str) -> str:
# start_as_current_span 同时做三件事:建 span、把它设成当前上下文、
# 退出 with 块时自动结束。后两件是关键 —— 之后在这个 with 块里
# 产生的任何 span 都会自动成为它的子节点,不需要手动传父节点。
with tracer.start_as_current_span("invoke_agent 销售总结") as span:
span.set_attribute("gen_ai.operation.name", "invoke_agent")
span.set_attribute("gen_ai.agent.name", "sales-summarizer")
# 业务维度:这是自动埋点永远补不上的部分,因为它不在任何库的调用签名里
span.set_attribute("app.workflow", "weekly_report")
resp = client.chat.completions.create(...)
span.set_attribute("gen_ai.usage.input_tokens", resp.usage.prompt_tokens)
return resp.choices[0].message.content
A 的唯一不可替代之处,是最后那个 app.workflow。 「这次执行属于周报流程」这个事实不在 openai 包的任何一个函数签名里,任何自动埋点都推断不出来。同类的还有租户 ID、实验分组、用户会话 ID。
除此之外它全是缺点:
| 问题 | 具体表现 |
|---|---|
| 漏埋 | 新加的调用点没人记得埋,trace 上出现无法解释的空白 |
| 埋错 | 属性名手打,input_tokens 写成 prompt_tokens 不会有任何报错,只是查询时对不上 |
| 规范漂移 | 01 篇 5 节那 118 个属性全是 development,改名时要全局搜索替换 |
| 框架内部看不见 | LangGraph 里一次 graph.invoke() 内部可能走了七个节点,手工埋点只能记成一个 span |
最后一条是硬伤。用了编排框架之后,你想看的东西大部分发生在框架内部,而框架内部你伸不进手。
2.1 装饰器是 A 的省事版本,不是另一类
各平台都提供了装饰器语法:
from langfuse import observe
@observe() # Langfuse
def summarize_sales(q: str): ...
import mlflow
@mlflow.trace # MLflow Tracing
def summarize_sales(q: str): ...
from traceloop.sdk.decorators import workflow
@workflow(name="weekly_report") # OpenLLMetry
def summarize_sales(q: str): ...
它们省掉了属性名手打,但改动面没变 —— 仍然是"每个想看的函数都要动一次"。归类上属于 A,不属于 B。
三、做法 B:库内自动埋点
改成这样:
# 整个应用只需要这三行,位置在所有业务 import 之前
from openinference.instrumentation.openai import OpenAIInstrumentor
OpenAIInstrumentor().instrument()
# 下面的业务代码一个字都不用改,每次 client.chat.completions.create()
# 都会自动产生一个带完整属性的 chat span
代价是你要为每个用到的库装一个 instrumentor。现成的有多少:
| 项目 | ★ | 许可证 | Python instrumentor 数 | 侧重 |
|---|---|---|---|---|
Arize-ai/openinference | 1,159 | Apache-2.0 | 37 | Agent 框架最全:google-adk、strands-agents、smolagents、pydantic-ai、dspy、claude-agent-sdk、mcp |
traceloop/openllmetry | 7,387 | Apache-2.0 | 32 | 向量库最全:pinecone、qdrant、milvus、weaviate、chromadb、lancedb、marqo |
open-telemetry/opentelemetry-python-genai | 31 | Apache-2.0 | 12 | OTel 官方,2026-05-12 才建仓,只覆盖主流的那几个 |
(数据日期 2026-08-21,gh api repos/OWNER/REPO 与仓库目录计数)
第三行值得单独说:OTel 官方正在把 GenAI 埋点收回自己的仓库。 opentelemetry-python-contrib 里原本的 instrumentation-genai/ 和 util/opentelemetry-util-genai 都标注了正在迁往 opentelemetry-python-genai。星数低不代表边缘 —— 判断这类基础设施仓库要看它是不是"上游",而不是看谁 star 了它。
3.1 内部机制:猴补丁挂在哪一层
拿 OpenInference 的 OpenAI 埋点器看,全文核心只有十几行:
# openinference/instrumentation/openai/__init__.py
from wrapt import wrap_function_wrapper
class OpenAIInstrumentor(BaseInstrumentor):
def _instrument(self, **kwargs):
openai = import_module("openai")
# 先把原函数存起来,_uninstrument 时要还回去
self._original_request = openai.OpenAI.request
self._original_async_request = openai.AsyncOpenAI.request
# wrapt 把 openai.OpenAI.request 这个属性换成一个代理对象,
# 代理内部持有原函数。调用时先开 span、记录请求属性,
# 再调原函数,拿到响应后补上 token 用量和输出属性。
wrap_function_wrapper("openai", "OpenAI.request", _Request(tracer=tracer, openai=openai))
wrap_function_wrapper("openai", "AsyncOpenAI.request", _AsyncRequest(...))
def _uninstrument(self, **kwargs):
# 还原。注意这里是直接赋值回去,不是 wrapt 的反向操作 ——
# 如果中间有第二个库也补了同一个函数,这次还原会把它一起抹掉
openai = import_module("openai")
openai.OpenAI.request = self._original_request
openai.AsyncOpenAI.request = self._original_async_request
挂点的选择比补丁技术本身重要得多。 这里补的是 OpenAI.request —— SDK 内部所有资源方法最终汇聚的那个传输层入口,而不是 client.chat.completions.create:
3.2 失效模式:版本一漂,静默无 span
同一个包里还有一行:
# openinference/instrumentation/openai/package.py
_instruments = ("openai >= 1.69.0",)
这个版本区间是硬约束。当它不满足时,你得到的不是报错,是零个 span。 OTel 的自动加载逻辑里,跳过分支长这样:
# opentelemetry/instrumentation/auto_instrumentation/_load.py
except DependencyConflictError as exc:
_logger.debug("Skipping instrumentation %s: %s", entry_point.name, exc.conflict)
continue # ← 跳过,继续下一个
except ModuleNotFoundError as exc:
_logger.debug("Skipping instrumentation %s: %s", entry_point.name, exc.msg)
continue
except ImportError:
_logger.exception("Importing of %s failed, skipping it", entry_point.name)
continue # ← K8s Operator 注入场景专门加的分支
三条跳过路径,两条记 debug 级别日志。默认日志级别下你什么都看不到,现象是"埋点装了、代码跑了、后台一条 trace 都没有"。
这是自动埋点最常见的一类线上事故,排查方式固定:
# 1. 确认实际装的库版本落在 instrumentor 声明的区间内。
# 这一步能解释绝大多数「装了但没数据」
python -c "import openai; print(openai.__version__)"
python -c "import importlib.metadata as m; print(m.requires('openinference-instrumentation-openai'))"
# 2. 确认 entry point 真的被注册了。没输出就说明包没装好或装错了环境
python -c "import importlib.metadata as m; \
print([(e.name, e.value) for e in m.entry_points(group='opentelemetry_instrumentor')])"
# 3. 看加载过程到底发生了什么。三条跳过路径记的都是 debug 级别,
# 不把这个 logger 调下来就什么都看不到
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("opentelemetry.instrumentation").setLevel(logging.DEBUG)
除了版本,另外三种静默失效:
| 失效方式 | 现象 | 原因 |
|---|---|---|
instrument() 调用得太晚 | 部分调用有 span,部分没有 | 业务模块已经 from openai import OpenAI 把类引用抓在手里了,之后再补 openai.OpenAI.request 补不到已经绑定的引用 |
| SDK 内部重构 | 升了一个小版本后 span 全没了 | 补丁挂的是私有方法。OpenAI.request 不在 SDK 的公开 API 契约里,改名 不算破坏性变更 |
| 两个埋点库都装了 | 每次调用产生两个 span,token 用量翻倍 | OpenInference 和 OpenLLMetry 补的是同一个函数,见第六节 |
第一条的规避方式是把 instrument() 放进最早执行的位置 —— 这恰好就是做法 C 存在的理由。
四、做法 C:进程内零代码
源码一个字不改,靠启动方式把埋点插进去。
4.1 Python:PYTHONPATH + sitecustomize
# 装好 instrumentor 之后,启动命令前面加一个词,就这样
opentelemetry-instrument python app.py
它做的事情比看上去简单。opentelemetry-instrument 本身是个只有几十行的启动器:
# opentelemetry/instrumentation/auto_instrumentation/__init__.py
filedir_path = dirname(abspath(__file__))
python_path.insert(0, filedir_path) # 把自己所在目录塞到 PYTHONPATH 最前面
environ["PYTHONPATH"] = pathsep.join(python_path)
executable = which(args.command)
execl(executable, executable, *args.command_args) # 用真正的命令替换掉当前进程
而那个目录里躺着一个文件:
# .../auto_instrumentation/sitecustomize.py —— 全文就这三行
from opentelemetry.instrumentation.auto_instrumentation import initialize
initialize()
sitecustomize 是 CPython 启动时自动 import 的特殊模块名,早于任何业务代码。于是链条闭合了:
4.2 Java:javaagent 字节码织入
# JVM 层面拦截类加载,在方法体前后织入计时代码,不需要 PYTHONPATH 这类技巧
java -javaagent:opentelemetry-javaagent.jar -jar app.jar
但 Java 侧的 GenAI 覆盖远不如 Python。opentelemetry-java-instrumentation 的 instrumentation/ 目录下,与 GenAI 相关的模块只有 openai 一个。Java 技术栈上想要框架级的 Agent 追踪,目前基本只能走做法 A 或 D。
4.3 Kubernetes:Operator 注入 initContainer
平台团队最想要的形态 —— 业务团队的镜像和代码都不动,加一条 Pod 注解:
spec:
template:
metadata:
annotations:
# 取值可以是 "true"(用当前 namespace 的默认 Instrumentation 资源)、
# 具体资源名,或者 "其他namespace/资源名" 做跨 namespace 引用
instrumentation.opentelemetry.io/inject-python: "true"
opentelemetry-operator(★1,747)的 admission webhook 看到这条注解后,往 Pod 里塞一个名为 opentelemetry-auto-instrumentation 的 initContainer,把埋点库拷进共享 volume,再改业务容器的 PYTHONPATH 和 OTEL_* 环境变量。启动后走的还是 4.1 那条链路。
业务团队只碰第一步 —— 这是做法 C 的全部价值:它把「谁有权改这件事」从业务团队转移到了平台团队。
第 6 步是落地时最常出问题的一环。注入的埋点库是预先构建好的,Python 版本或 libc 与业务容器对不上时会 ImportError。加载器为此专门留了一条 except ImportError 分支「跳过这一个而不是整体失败」—— 这条分支的存在本身就说明它常见。
最后一步回到 4.1 那条链, 因此天然免疫「补太晚」。
已知限制(来自官方文档,不是推测):
| 语言 | 限制 |
|---|---|
| Go | 走 eBPF sidecar,不支持多容器 Pod;需要 privileged: true + runAsUser: 0;还得设 OTEL_GO_AUTO_TARGET_EXE |
| Deno | OTel 集成本身还不稳定,要 --unstable-otel |
| 通用 | 同一个容器里某些语言组合不能同时注入 |
Go 那行的三个限制叠起来,在多数生产集群的 PodSecurity 策略下直接过不了 —— 这是选型时要提前确认的,不是上线后再说的。
五、做法 D:进程外旁路
进程完全不碰。两个子路线:
| 子路线 | 采集点 | 代表 | 详见 |
|---|---|---|---|
| 内核探针 | uprobe 挂在 TLS 库的读写函数上,在加密前 / 解密后拿到明文 HTTP 载荷 | OBI(opentelemetry-ebpf-instrumentation) | 03 篇 |
| 网络代理 | 应用把 base_url 指向网关,网关转发时顺手记账 | LiteLLM Proxy、Helicone、Envoy AI Gateway | 06 篇 |
两者的共同天花板:它们只能看到"离开进程的东西"。 一次 LangGraph 执行内部走了哪几个节点、哪个条件边被选中、Agent 在第几轮决定放弃 —— 这些全部发生在进程内存里,从来没有变成网络包,旁路永远看不到。
反过来,它们有一个 B 和 C 都给不了的性质:不可绕过。 业务方忘记装埋点库、故意关掉埋点、或者干脆用了个没人写过 instrumentor 的冷门 SDK,旁路照样记得到。这正是计费口径必须放在这一层的原因。
六、覆盖率对照
四条路线各自能看见什么:
| 想看的东西 | A 手工 | B 库内 | C 零代码 | D 旁路 |
|---|---|---|---|---|
| 模型调用耗时、token 用量 | ✅ 手写 | ✅ | ✅ | ✅ |
| 框架内部节点(LangGraph 的每个 node) | ❌ | ✅ 只有这条 | ✅ 只有这条 | ❌ |
| 工具调用与其参数 | ✅ 手写 | ✅ | ✅ | 部分(走 MCP over HTTP 才行) |
| 向量库检索 | ✅ 手写 | ✅ | ✅ | 部分(OBI 支持六家向量库) |
| 业务维度(租户、实验分组、流程名) | ✅ 只有这条 | ❌ | ❌ | ⚠️ 网关侧可注入 |
| 未装埋点库的服务 | ❌ | ❌ | ❌ | ✅ 只有这条 |
| 不可伪造的计费口径 | ❌ | ❌ | ❌ | ✅ 只有这条 |
四项能力各有唯一的来源,且分散在谱系的两端和中段。
这就是为什么生产系统必然是混用,而不是选一个。
七、混用时的两个坑
7.1 同一个函数被补两次,而且关不掉一个
openinference-instrumentation-openai 和 opentelemetry-instrumentation-openai-v2 补的是同一个 openai 包。两个都装上,一次调用产生两个 span:
# ❌ 两个埋点库同时生效
# 后果不只是 span 数量翻倍 —— 成本报表按 gen_ai.usage.input_tokens
# 求和时,同一次调用的 token 会被算两遍,账单直接翻倍
OpenAIInstrumentor().instrument() # OpenInference
OpenAIInstrumentorV2().instrument() # OTel 官方
做法 B 下这个问题好办 —— 少调一行就行。做法 C 下它几乎无解,原因藏在两个包的 pyproject.toml 里:
# openinference-instrumentation-openai/pyproject.toml
[project.entry-points.opentelemetry_instrumentor]
openai = "openinference.instrumentation.openai:OpenAIInstrumentor"
# opentelemetry-instrumentation-openai-v2/pyproject.toml
[project.entry-points.opentelemetry_instrumentor]
openai = "opentelemetry.instrumentation.openai_v2:OpenAIInstrumentor"
两个不同的包,注册在同一个组下、用了同一个名字 openai。 而关闭开关是按这个名字匹配的:
# opentelemetry/instrumentation/auto_instrumentation/_load.py
package_to_exclude = environ.get(OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, [])
# ...
for entry_point in entry_points(group="opentelemetry_instrumentor"):
if SKIPPED_INSTRUMENTATIONS_WILDCARD in package_to_exclude: # 值是 "*"
break
if entry_point.name in package_to_exclude: # ← 按名字匹配
_logger.debug("Instrumentation skipped for library %s", entry_point.name)
continue
于是:
# 这一行会把两个都关掉,而不是只关掉其中一个
export OTEL_PYTHON_DISABLED_INSTRUMENTATIONS="openai"
零代码模式下没有办法只保留其中一个 —— 只能卸载掉不要的那个包,或者退回做法 B 手动调用。
这个坑在做法 C 下格外容易踩:entry point 是"装了就生效",没人会在启动日志里看到"你装了两个管 openai 的埋点库"。排查方式是把 opentelemetry.instrumentation 这个 logger 调到 DEBUG,看它到底加载了几个:
# 放在应用启动最前面,或者用 opentelemetry-instrument 时写进 sitecustomize 之外的
# 任意早期位置。三条跳过路径记的都是 debug,不开这个什么都看不到
import logging
logging.getLogger("opentelemetry.instrumentation").setLevel(logging.DEBUG)
logging.basicConfig(level=logging.DEBUG)
7.2 B/C 与 D 的 span 对不上号
同一次模型调用,进程内的 instrumentor 产生一个 chat span,网关侧又产生一个自己的 span。想让它们在同一棵树里,前提是 W3C traceparent 头能一路传下去:
# 进程内埋点会自动注入 traceparent 到出站 HTTP 头,但前提是
# HTTP 客户端也被埋点了。只装 openai 的埋点器、没装 httpx 的,
# 出站请求就不带 traceparent —— 网关侧那个 span 于是成了孤儿根节点。
# 症状:后台里同一次调用出现两棵独立的树,怎么找都关联不上。
网关侧的 span 如果成了孤儿,成本归因就只剩网关自己那份数据可用,进程内那些"这次属于哪个业务流程"的属性关联不上。06 篇会说这一层具体怎么打通。
八、怎么选
| 情况 | 走哪条 |
|---|---|
| 用了 LangGraph / LlamaIndex / CrewAI 这类编排框架 | B 或 C 必选。 框架内部结构是排查的主要对象,手工埋点看不到 |
| 代码归业务团队、观测归平台团队 | C。 平台团队没有改业务代码的权限,Pod 注解是唯一的抓手 |
| 要算钱、要防绕过 | D 的网关子路线必选,且计费只认这一份数据 |
| 多语言混合,Java / Go 占比高 | D 为主。 Java 侧 GenAI 埋点只有 openai 一个模块,Go 侧靠 eBPF |
| 需要"这次执行属于哪个业务流程" | 补 A。 只在根 span 上加几个属性即可,不必全面手工埋 |
| 只是想在本地看看 trace 长什么样 | B。 三行代码,装个 Phoenix 直接看 |
一句话版本:B/C 拿结构,D 拿账,A 补业务维度。 三者不冲突,冲突的是"以为选一个就够了"。
下一篇 → 03 - eBPF 旁路采集
← 回到 专题索引 · Agent Infra 板块总览