跳到主要内容

一行 init 的魔法:OTel 装配 + wrapt 自动打桩

30 秒导读: 你写下 openlit.init() 这一行,回车之后什么都没变——你的 OpenAI、LangChain、Postgres 调用代码一字没动。但从这一刻起,每一次 LLM 调用都会自动生成 trace、算出 token 花了多少钱、记下耗时。本章讲清这行魔法的内部:它怎么在不碰你代码的前提下,接管几十种第三方库。

本章是全景导览(index.md)之后的第一站。它只讲**「装配机制」**——SDK 如何把探针挂上去;至于挂上去之后,单次调用怎么被拆成 span / event / metric 的具体内容,那是下一章 02-span-cost-metrics.md 的事,本章不碰。


1. 这是什么(零基础也能懂)

先说要解决的问题

你想给一个已经写好的 AI 应用加「可观测性」(observability,即「看得见程序内部在干什么」——每次模型调用的耗时、token 数、报错、成本)。

最笨的办法:在每一处 client.chat.completions.create(...) 前后手写计时和日志。项目里有几百处调用、还混着 LangChain / 向量库 / HTTP,这条路根本走不通。

OpenLIT 的答卡是一句话:

import openlit
openlit.init() # 就这一行

# 下面全是你原来的代码,一个字不用改
from openai import OpenAI
client = OpenAI()
client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "你好"}],
) # ← 这次调用已经被自动追踪了

一句话直觉:给方法「套壳」

把它想成给函数套一层壳。原来的 Completions.create 还在,只是 OpenLIT 在运行时把它换成了「壳版」:

你调用 create()


┌─────────────────────────┐
│ OpenLIT 的壳(wrapper) │ ← 开一个 span、记开始时间
│ │ │
│ ▼ │
│ 原版 create() ────────┼──→ 真去请求 OpenAI,拿回结果
│ │ │
│ ▼ │
│ 记 token/耗时/成本、结束 │ ← 把遥测发出去
└─────────────────────────┘


把原版的结果原样还给你(你完全无感)

「换成壳版」这个动作有个术语叫 monkey-patching(猴子补丁,即运行时替换已存在的函数/方法)。OpenLIT 用 wrapt 这个库来干得干净、稳妥。

它能做到什么

  • 零代码改动:你不 import 任何 OpenLIT 的追踪类,只调一次 init()
  • 广覆盖:一次 init() 能同时接管几十个库——OpenAI、Anthropic、LangChain、LlamaIndex、CrewAI、向量库(Chroma/Pinecone/Qdrant)、HTTP 客户端、Postgres 等(清单见 _instrumentors.py:8MODULE_NAME_MAP)。
  • 按需装配:你没装的库,它自动跳过,不报错。
  • 不重复造轮子:HTTP 框架/客户端这类,它直接复用官方 OpenTelemetry 的 instrumentor;只有 AI 相关的库才用自己写的。

本节到此不碰底层。记住一件事就够:init() = 装好遥测管道 + 给一堆库的方法套壳。 下面逐层拆。


2. 顶层全景(它大概怎么转)

怎么读这张图

init() 内部是一条从上到下的流水线:先把配置理顺,再把 OpenTelemetry 三件套(下面解释)搭起来,最后才是「发现 instrumentor → 逐个套壳」。

openlit.init(...) ┌─ 依据:__init__.py:184 init() ─┐


① 配置归一 别名/大小写归一、环境变量兜底、
normalize + env 合并 + service_name service_name/application_name 迁移
│ __init__.py:254-327

② controller_mode 默认禁用清单 agent_observability 模式下,
apply_controller_mode_defaults 自动禁掉一批「会重复打桩」的库
│ __init__.py:329-338 / :166

③ 装 OTel 三件套(复用已有 provider) Tracer(链路)/ Meter(指标)/
setup_tracing / setup_meter / Logger(事件)—— 已配置就复用
setup_events otel/tracing.py:35 等


④ 写进单例配置 OpenlitConfig 全局唯一,后面每个 wrapper 都读它
config.update_config(...) _config.py:57


⑤ 动态发现所有 instrumentor 按 INSTRUMENTOR_MAP 反射 import,
get_all_instrumentors() 能实例化的才留下
│ _instrumentors.py:228

⑥ 逐个「按需装配」 库装了才套壳;官方 OTel 的走
instrument_if_available(每个) 标准 instrument(),自研的走扩展参数
│ __init__.py:126 / :436

完成 —— 你的库方法已被替换成「壳版」

「OpenTelemetry 三件套」是什么

OpenTelemetry(简称 OTel,业界标准的遥测框架)把可观测数据分三类,各有一个「provider(提供者,负责产出该类数据的工厂)」:

三件套术语装什么数据OpenLIT 里的装配函数
TracerTracerProvider链路 span(一次调用的时间线)setup_tracing(otel/tracing.py:35)
MeterMeterProvider指标 metric(token 数、成本、耗时直方图)setup_meter(otel/metrics.py:144)
LoggerLoggerProvider事件 event(以日志形式发出的结构化事件)setup_events(otel/events.py:35)

三件套具体产出什么内容是第 2 章的主题。本章只关心:init() 怎么把它们搭起来、且不和用户已有的 OTel 配置打架。

部件一句话职责

部件干什么在哪
init()总指挥,串起下面全部步骤__init__.py:184
OpenlitConfig全局单例配置,wrapper 运行时都读它_config.py:9
MODULE_NAME_MAPinstrumentor 名 → 需要哪个 pip 包_instrumentors.py:8
INSTRUMENTOR_MAPinstrumentor 名 → 具体的 Instrumentor 类路径_instrumentors.py:129
INSTRUMENTOR_ALIASES用户随手名 → 规范名(如 aiohttpaiohttp-client)_instrumentors.py:86
get_all_instrumentors反射发现并实例化所有可用 instrumentor_instrumentors.py:228
instrument_if_available库装了才装配,并区分官方/自研两条路__init__.py:126
OpenAIInstrumentor自研 instrumentor 范例,用 wrapt 套壳instrumentation/openai/__init__.py:153

3. 核心原理(逐个机制,由浅入深)

3.1 配置归一:三个来源,谁说了算

要解决的小问题: 一个配置项(比如 service_name、要禁用哪些库)可以从三个地方来——函数参数、环境变量、默认值。谁优先?

规则(优先级从高到低):

  1. 你显式传给 init() 的参数
  2. 环境变量(OPENLIT_*)
  3. 代码里的默认值

实现手法很朴素:只有当参数还是默认值时,才拿环境变量去填。 例如:

# 依据:__init__.py:277-278 —— 示意,非源码
if environment == "default" and "environment" in env_config:
environment = env_config["environment"] # 参数没给,才用 env

环境变量本身由 build_config_from_environment()OPENLIT_* 变量解析成一个 dict(cli/config.py:171),init()__init__.py:271-322 逐项「参数为默认值就兜底」。

service_name / application_name 迁移

老版本用 application_name,新版本改叫 service_name(对齐 OTel 语义)。两个都还在,得决定用哪个:

service_name 显式给了? ── 是 ──▶ 用 service_name
│否
application_name 显式给了? ── 是 ──▶ 用 application_name(向后兼容,不吭声)
│否
都没给 ─────────▶ 交给环境变量兜底
依据:__init__.py:259-267 final_service_name

注意:service_name 优先级高于 application_name,两者最终都归一到同一个内部变量 final_service_name,并且映射到同一个环境变量键(__init__.py:281-287)。

别名与大小写归一

用户传 disabled_instrumentors=["aiohttp"],但内部规范名是 aiohttp-client。归一在一个入口统一做:

# 依据:_instrumentors.py:104 normalize_instrumentor_name —— 示意,非源码
lowered = name.lower() # 大小写不敏感
return INSTRUMENTOR_ALIASES.get(lowered, lowered) # 有别名就换,没有就原样

所有路径(init 参数、env 变量、配置文件)都走 normalize_instrumentor_names(_instrumentors.py:117),保证一致。别名表见 INSTRUMENTOR_ALIASES(_instrumentors.py:86),例如 http→httpxdigitalocean→pydo

3.2 controller_mode:防「双重打桩」的默认禁用清单

要解决的小问题: 当 OpenLIT 作为「控制面(controller)」托管 agent 可观测性时,底层 LLM/HTTP 调用可能已经被别的层打过桩了。OpenLIT 再打一遍,就会同一次调用出两条 span,数据翻倍。

思路: 进入 agent_observability 模式时,自动把一批「容易重复」的 instrumentor 加进禁用名单——这批是硬编码的清单 CONTROLLER_MANAGED_DISABLED_INSTRUMENTORS(__init__.py:75-94,含 openai/anthropic/httpx/requests 等)。

# 依据:__init__.py:166 apply_controller_mode_defaults —— 示意,非源码
if controller_mode != "agent_observability":
return normalized_disabled # 非该模式,原样返回
# 该模式:把用户禁用项 + 默认禁用清单去重合并
merged = list(dict.fromkeys(normalized_disabled + DEFAULT_DISABLED))
return merged

dict.fromkeys(...) 是个惯用法:去重且保序。合并后,该模式还会自动给每个 span 打一个 openlit.controller.mode=agent_observability 的自定义属性(__init__.py:333-338),方便后端识别来源。

3.3 OTel provider 复用:不砸掉用户已有的配置

要解决的小问题: 用户可能已经配了 OpenTelemetry(自己 new 了一个 TracerProvider)。OpenLIT 如果无脑再 set 一个,就会覆盖掉用户的导出配置。

思路(两道闸):

  1. 模块级全局标志 TRACER_SET(otel/tracing.py:32):init() 被重复调也只装一次。
  2. 探测已有 provider:如果当前已是一个 SDK 的 TracerProvider,就复用,绝不新建。
# 依据:otel/tracing.py:55-70 setup_tracing —— 示意,非源码
if not TRACER_SET:
existing = trace.get_tracer_provider()
if isinstance(existing, TracerProvider):
logger.info("Detected existing TracerProvider, reusing it") # 复用
else:
# 没有 → 自己建一个,挂上 resource 和 exporter
trace.set_tracer_provider(TracerProvider(resource=resource))
...
TRACER_SET = True

三件套一个套路,各有各的全局标志:

provider复用探测全局标志依据
Tracerisinstance(existing, TracerProvider)TRACER_SETotel/tracing.py:55-70
Meterisinstance(existing, MeterProvider)METER_SETotel/metrics.py:161-166
Logger(events)isinstance(existing, SDKLoggerProvider)EVENTS_SETotel/events.py:63-68

导出目标怎么定(exporter): 三者都遵循同一套 fallback ——先看 OTEL_*_EXPORTER 环境变量指定的多导出器;没指定就看有没有 OTEL_EXPORTER_OTLP_ENDPOINT,有则走 OTLP(网络导出),没有则退化成 Console(打到控制台)。见 otel/tracing.py:86-133

setup_meter 的特殊之处: 它不返回一个 meter,而是返回一整个 metrics_dict——预先创建好的一大批直方图/计数器(token 用量、成本、TTFT、MCP 指标、agent 指标等),见 otel/metrics.py:235-383。这个 dict 存进单例,后面 wrapper 直接取用,不必自己 create。

3.4 OpenlitConfig 单例:所有 wrapper 的共享黑板

要解决的小问题: init() 里算出来的配置(环境名、是否记录内容、定价表、metrics_dict……),几百个 wrapper 在运行时怎么拿到?

思路:进程内唯一的单例,当共享黑板用。

# 依据:_config.py:29 __new__ —— 示意,非源码
class OpenlitConfig:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls.reset_to_defaults() # 首次创建时铺默认值
return cls._instance
  • 配置存在类属性上(cls.environmentcls.metrics_dict 等),所以任意地方 OpenlitConfig.metrics_dict 就能读到,不必传引用。
  • init() 最后调 config.update_config(...)(_config.py:57)把算好的值一次性刷进去(__init__.py:413)。
  • 典型消费点:OpenAIInstrumentor._instrumentmetrics = OpenlitConfig.metrics_dict(instrumentation/openai/__init__.py:168)。

3.5 动态发现:一张表,反射出几十个 instrumentor

要解决的小问题: 支持的库有几十个,不可能在 init() 里手写几十个 importif

思路:用「名字 → 类路径」的字符串表,运行时反射(reflection,即按字符串名去 import 模块、取类)出来。

两张表分工:

键 → 值用途
MODULE_NAME_MAPinstrumentor 名 → 需要的 pip 包名判断「这个库用户装没装」
INSTRUMENTOR_MAPinstrumentor 名 → Instrumentor 类的完整路径反射 import 出类

例如 "openai" 在前者映射到包 "openai",在后者映射到类 "openlit.instrumentation.openai.OpenAIInstrumentor"

发现流程:

# 依据:_instrumentors.py:206 / :228 —— 示意,非源码
def get_instrumentor_class(name):
module_path, class_name = INSTRUMENTOR_MAP[name].rsplit(".", 1)
module = importlib.import_module(module_path) # 按字符串 import
return getattr(module, class_name) # 取出类

def get_all_instrumentors():
instances = {}
for name in INSTRUMENTOR_MAP:
cls = get_instrumentor_class(name)
if cls:
try:
instances[name] = cls() # 能实例化的才留
except Exception:
pass # 失败就跳过,不炸
return instances

关键:import 或实例化失败都被静默吞掉(_instrumentors.py:224:242)。这是「广覆盖」能成立的前提——某个可选依赖没装、或版本不兼容,只是少一个 instrumentor,绝不拖垮整个 init()

3.6 按需装配:库装了才套壳,且区分官方/自研两条路

init() 拿到 get_all_instrumentors() 的结果后,逐个交给 instrument_if_available(__init__.py:436-437)。这个函数做三件事:

instrument_if_available(name, instrumentor, config, disabled)

├─ ① 在禁用名单里? ── 是 ──▶ 跳过,记日志
│ │否
├─ ② 对应的库真装了吗? ── 否 ──▶ 跳过(module_exists 探测)
│ │是
└─ ③ 是官方 OTel 的 instrumentor 吗?
├─ 是 ─▶ instrumentor.instrument() ← 标准无参
└─ 否 ─▶ instrumentor.instrument(environment=, ← 自研带扩展参数
application_name=, pricing_info=, ...)
依据:__init__.py:126-163

② 库装没装,怎么探测: module_exists(__init__.py:97)按 MODULE_NAME_MAP 给的包名,用 find_spec 逐级检查点号路径(处理 azure.ai.inference 这种嵌套模块):

# 依据:__init__.py:97-103 module_exists —— 示意,非源码
parts = module_name.split(".")
for i in range(1, len(parts) + 1):
if find_spec(".".join(parts[:i])) is None: # 任一级缺失
return False # 就算没装
return True

③ 为什么要分官方 vs 自研: 二者的 instrument() 签名不同。官方 OpenTelemetry 的 instrumentor(FastAPI/Flask/httpx/requests 等)是标准无参调用;OpenLIT 自研的需要额外喂进环境名、定价表、是否记录内容等。判定靠一张硬编码集合:

# 依据:__init__.py:106 is_opentelemetry_instrumentor —— 示意,非源码
opentelemetry_instrumentors = {
"asgi", "django", "fastapi", "flask", "starlette",
"httpx", "requests", "urllib", "urllib3", "aiohttp-client", ...
}
return instrumentor_name in opentelemetry_instrumentors

整个装配循环被 try/except 兜住(__init__.py:162):某个库装配抛错,只记一条 error,继续装下一个——又一次「一个坏了不影响其余」。

3.7 范例:OpenAIInstrumentor 怎么用 wrapt 套壳

前面都是「调度」。真正把方法换成壳版的动作,在各个 instrumentor 里。以 OpenAIInstrumentor 为范例。

它继承 OTel 的 BaseInstrumentor(instrumentation/openai/__init__.py:153),这是官方约定的基类——你实现 _instrument(),框架负责在 instrument() 被调时触发它。

核心工具是 wrapt 的 wrap_function_wrapper:给「某模块里某个类的某方法」挂一个包装器。

# 依据:instrumentation/openai/__init__.py:185-189 —— 真实调用
wrap_function_wrapper(
"openai.resources.chat.completions", # 目标模块
"Completions.create", # 目标:类.方法
chat_completions(*sa), # 包装器工厂产出的 wrapper
)

三个设计细节,值得学:

(1) _standard_args —— 一次算好,到处复用。 每个 wrapper 都需要同一串上下文参数(version、environment、tracer、pricing_info、metrics……)。_instrument 里算一次,打包成元组 sa,后面几十处 wrap_*(*sa) 直接解包(instrumentation/openai/__init__.py:118-182)。避免几十行重复传参。

(2) _safe_wrap —— 老版本 SDK 没有的方法,静默跳过。 OpenAI 的 SDK 在演进,新方法(videos、conversations、responses.cancel 等)老版本里不存在。硬 wrap 会抛 ModuleNotFoundError,于是:

# 依据:instrumentation/openai/__init__.py:143-150 _safe_wrap —— 示意,非源码
def _safe_wrap(module, class_method, wrapper):
try:
wrap_function_wrapper(module, class_method, wrapper)
except ModuleNotFoundError:
logger.debug("Skipping %s.%s — module not in this openai version", ...)

核心方法(chat/embedding/responses 的 create/parse)用硬 wrap_function_wrapper(它们各版本都有),边缘/新方法用 _safe_wrap——这就是同一个 _instrument 里两种 wrap 混用的原因(对比 :185:221)。

(3) 覆盖面靠「逐个方法列举」。 _instrument 里没有循环魔法,而是老老实实把 OpenAI 的几十个端点方法一条条 wrap:chat completions、embeddings、images(generate/edit/variation)、audio(TTS/STT/翻译)、moderations、batches、fine-tuning、vector stores、files、videos、conversations……同步版和异步版各一遍(Completions.createAsyncCompletions.create)。全景见 instrumentation/openai/__init__.py:161-706

注意边界:本节只讲「方法被换成了壳版」。壳里到底怎么读参数、算 token、组 span——即 chat_completions(*sa) 返回的那个 wrapper 内部——是 02-span-cost-metrics.md 的内容。


4. 巧妙之处(可借鉴的技术)

  • 「失败即跳过」贯穿全链路。 发现阶段吞异常(_instrumentors.py:242)、装配阶段吞异常(__init__.py:162)、单方法 wrap 吞 ModuleNotFoundError(instrumentation/openai/__init__.py:147)。可选依赖多、版本杂的库,靠这个才能做到「装了才追、没装不炸」。

  • provider 复用而非霸占。isinstance(existing, TracerProvider) 探测 + 模块级 *_SET 标志(otel/tracing.py:55),既不覆盖用户已有 OTel 配置,也保证重复 init() 幂等。

  • 字符串表 + 反射 = 可扩展。 加一个新库支持,只需在 MODULE_NAME_MAP / INSTRUMENTOR_MAP 各加一行(_instrumentors.py:8 / :129),不动任何调度逻辑。

  • 别名归一收敛在单一入口。 所有来源(参数/env/配置文件)都过 normalize_instrumentor_names(_instrumentors.py:117),别名和大小写只在一处处理,不散落。

  • dict.fromkeys 去重保序 用于合并禁用清单(__init__.py:172-177)——比 set 好在保持顺序,日志可读。


5. 边界与局限(诚实)

  • 靠 import 时机。 monkey-patching 在 init() 那一刻替换方法。若你在 init() 之前就已经把某个方法绑定成了局部引用(如 create = client.chat.completions.create),那个旧引用不会被换。常规写法不受影响。

  • 进程级单例。 OpenlitConfig*_SET 都是进程内全局。多套配置并存、或 init() 后再改配置,不是它的设计目标。

  • 禁用粒度到「库」。 disabled_instrumentors 按 instrumentor 名禁用;文档特别提醒 urllib3 要和 requests 分开禁(requests 内部用 urllib3),见 __init__.py:226-228 的 docstring。

  • 无效名只警告不报错。 传了 MODULE_NAME_MAP 里没有的名字,只打一条 warning 并尝试给出拼写建议(__init__.py:341-357),不会中断 init()

  • _uninstrument 是空的。 OpenAIInstrumentor._uninstrument(instrumentation/openai/__init__.py:708)是 pass——套上壳之后,SDK 没提供「优雅摘除」的路径。


6. 横向对比(同 shelf 视角)

本章讲的是装配层,它是 OpenLIT 全套能力的地基:

与其他 agent 可观测方案相比,OpenLIT 的取舍是**「不发明格式,贴 OTel 标准」**:三件套全用 OpenTelemetry 原生 provider,自研 instrumentor 也套 BaseInstrumentor 基类。代价是要跟着 OTel 语义走;好处是任何吃 OTLP 的后端都能直接接。


7. 代码地图(导航索引)

用符号名 grep 定位比行号更抗漂移。

主题文件路径符号名
init 总入口sdk/python/src/openlit/__init__.pyinit
按需装配 + 官方/自研分叉sdk/python/src/openlit/__init__.pyinstrument_if_available
库是否已安装的探测sdk/python/src/openlit/__init__.pymodule_exists
官方 OTel instrumentor 判定sdk/python/src/openlit/__init__.pyis_opentelemetry_instrumentor
controller_mode 默认禁用sdk/python/src/openlit/__init__.pyapply_controller_mode_defaults / CONTROLLER_MANAGED_DISABLED_INSTRUMENTORS
单例配置sdk/python/src/openlit/_config.pyOpenlitConfig / update_config
名→包 映射sdk/python/src/openlit/_instrumentors.pyMODULE_NAME_MAP
名→类路径 映射sdk/python/src/openlit/_instrumentors.pyINSTRUMENTOR_MAP
别名归一sdk/python/src/openlit/_instrumentors.pyINSTRUMENTOR_ALIASES / normalize_instrumentor_name
动态发现实例化sdk/python/src/openlit/_instrumentors.pyget_all_instrumentors / get_instrumentor_class
Tracer 装配 + 复用sdk/python/src/openlit/otel/tracing.pysetup_tracing / TRACER_SET
Meter 装配 + metrics_dictsdk/python/src/openlit/otel/metrics.pysetup_meter / METER_SET
Logger/events 装配sdk/python/src/openlit/otel/events.pysetup_events / EVENTS_SET
环境变量解析sdk/python/src/openlit/cli/config.pybuild_config_from_environment
wrapt 套壳范例sdk/python/src/openlit/instrumentation/openai/__init__.pyOpenAIInstrumentor._instrument / _safe_wrap / _standard_args