跳到主要内容

数据截至 (上游 commit 0261ea4f33d4)

追踪层:@track 怎么把一次函数调用变成 span 树

30 秒导读: Opik 的 Python SDK 只要你在函数上贴一个 @track,就能把这次调用记成一个 span(一段带起止时间、输入、输出的执行片段);函数里再调别的被 track 的函数,就自动变成子 span, 整棵树挂在一个 trace(一次完整请求的根)下。本章只讲痕迹是怎么在内存里长出来的—— 怎么造、挂给谁、什么时候结束。至于这些对象怎么发到后端,是第 2 章的事。


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

一句话定义: @track 是一个 Python 装饰器,它把「谁调用了谁」这件运行时的事实,记成一棵可以画出来的树。

它解决什么问题。 你写了一个 LLM agent:入口函数取用户问题 → 调检索 → 调模型 → 调工具 → 拼答案。 线上出错时你想知道:哪一步慢、哪一步的 prompt 长什么样、模型返回了什么、token 花了多少。 print 打不出层级关系,日志也拼不回一次调用的全貌。

它给你什么。 贴一个装饰器,你就得到:

  • 每个被 track 的函数一段 span:名字、类型、起止时间、输入参数、返回值。
  • 函数调用的嵌套关系自动变成 span 的父子关系
  • 最外层那次调用额外生成一个 trace,作为整棵树的根。
  • 异常自动记成 error_info(异常类型 + traceback),且不改变你原来的异常行为

用起来什么样:

# 示意,非源码
from opik import track

@track # 裸用,不带括号
def retrieve(query):
return ["doc1", "doc2"]

@track(type="llm", tags=["prod"]) # 带参数用
def answer(query):
docs = retrieve(query) # 这次调用自动成为子 span
return f"based on {docs}"

answer("what is opik?")
# 得到:trace「answer」 → 根 span「answer」 → 子 span「retrieve」

一句话直觉: 把它当成函数调用栈的录像机。Python 自己有一个调用栈,@track 在旁边同步维护一份「影子栈」;影子栈的栈顶就是「当前 span」,谁进栈谁就当下一个 span 的爹。

术语先钉死(本章一词一义,不再变):

术语含义
span一段执行片段的记录对象,内存里是 SpanData
trace一次完整调用的根记录,内存里是 TraceData
栈 / context 栈存在 contextvars 里的那份 span 影子栈
策略@track 针对不同函数形态选的四种跟踪方式之一

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

2.1 一次被 track 的调用,走这六步

用户函数被调用


① 开关检查 ──关──▶ 直接调原函数,零开销
│开

② 造 span(必要时连 trace 一起造) ← 看栈里现在有什么


③ 压栈:让嵌套调用能找到自己这个爹


④ 跑用户代码(抛异常也照样往下走)


⑤ 出栈:填 end_time、输出、error_info


⑥ 交给上报层 ──▶ 见第 2 章

第 ① 步在每个 wrapper 的第一行:tracing_runtime_config.is_tracing_active() 不通过就原样调用(sdks/python/src/opik/decorator/base_track_decorator.py:328-329_tracked_sync)。 开关只在调用开始时读一次——调用中途关掉追踪,这次调用仍会被完整记录, track 的 docstring 明确写了这条语义(base_track_decorator.py:108-110)。

2.2 部件一句话职责

部件干什么文件
BaseTrackDecorator装饰器骨架:分派策略、包裹前后钩子decorator/base_track_decorator.py
OpikTrackDecorator默认实现,opik.track 就是它的实例方法decorator/tracker.py
OpikContextStorage那份影子栈本体(contextvars + 不可变 tuple)context_storage.py
create_span_respecting_context决定新 span 挂给谁decorator/span_creation_handler.py
generator_wrappers生成器专用的懒开始 / 聚合结束decorator/generator_wrappers.py
opik_context给用户的读写当前 span/trace 的 APIopik_context.py
arguments_helpers / opik_args参数打包、opik_args 旁路参数解析decorator/ 下同名模块

2.3 栈和树的对应关系

栈是过程,树是结果。看这张对照就懂了(栈顶在右):

调用嵌套 栈操作 栈内容
outer() push A [A]
└ inner1() push B [A, B]
返回 pop B [A]
└ inner2() push C [A, C]
返回 pop C [A]
返回 pop A [] ← 栈空了,顺手把 trace 也结掉

出栈那一刻发现栈空,就意味着「最外层那个函数刚返回」,于是连 trace 一起收尾—— 这条判断是 pop_end_candidate_trace_data 的核心(base_track_decorator.py:682-686),3.5 节细讲。


3. 核心原理

3.1 track() 的两种形态:裸用与带参

它要解决的小问题: @track@track(name="x") 是两种完全不同的 Python 语法, 前者把函数当第一个位置参数传进来,后者要求你返回一个装饰器。同一个方法要同时接住这两种用法。

做法:看第一个参数是不是 callable。

# 真实源码节选,base_track_decorator.py:128-148
if callable(name):
# Decorator was used without '()'.
func = name
return self._decorate(func=func, track_options=track_options)

track_options.name = name

def decorator(func: Callable) -> Callable:
...

name 参数的类型签名因此写成 Optional[Union[Callable, str]]base_track_decorator.py:62)—— 它一个参数身兼两职。所有配置项先被打包成 TrackOptions 数据类(decorator/arguments_helpers.py:60-78), 注意打包时 name=None先写死的base_track_decorator.py:113), 只有走带参分支才在第 137 行补回真正的名字;裸用分支下名字为 None, 留到后面由 inspect_helpers.get_function_name(func) 兜底(decorator/tracker.py:34-38)。

带参分支还多一件事:entrypoint=True 时把包好的函数注册进 agent runner (_apply_entrypointbase_track_decorator.py:623-643)——这是 agent 部署用的,与追踪本身无关。

3.2 四种跟踪策略:_decorate 的分派

它要解决的小问题: 「函数什么时候算结束」根本不是一个答案。普通函数 return 就结束; 生成器 return 时才刚开始吐值;返回 stream 对象的 LLM 调用,函数早返回了但内容还在流。 span 的结束时刻必须跟着「真正的活儿干完」走。

分派逻辑是四行 ifbase_track_decorator.py:179-197):

# 真实源码节选,base_track_decorator.py:179-197
if inspect.isgeneratorfunction(func):
return self._tracked_sync_generator(...)
if inspect.isasyncgenfunction(func):
return self._tracked_async_generator(...)
if inspect_helpers.is_async(func):
return self._tracked_async(...)
return self._tracked_sync(...)

前面还有一道幂等闸:函数上已经有 opik_tracked 属性就原样返回,避免被套两层 (base_track_decorator.py:176-177)。每个 wrapper 结尾都会打上这个标记 (如 base_track_decorator.py:372)。

is_async 不只看 iscoroutinefunction,还会穿透 __wrapped__ 再看一次—— 因为被别的装饰器包过的 async 函数会骗过标准判定(decorator/inspect_helpers.py:38-47)。

四种策略的真正区别,全在「span 在不在栈里」这一件事上。 这是类 docstring(base_track_decorator.py:155-174)写明的三类语义,展开成表:

策略判定入口span 何时开始span 何时结束在栈里的时段能当父 span 吗
普通同步/异步函数_tracked_sync / _tracked_async函数被调用时函数返回/抛出时整个函数执行期间,任意嵌套调用都挂得上
生成器 / 异步生成器_tracked_sync_generator / _tracked_async_generator第一次 __next__StopIteration只在 __next__ 执行期间只能当「在 __next__ 里创建的 span」的父
返回 stream 对象的调用_streams_handler 认出返回值函数被调用时stream chunk 耗尽时函数返回后立刻被弹出不能

为什么生成器是这个语义?因为 SyncTrackedGenerator.__next__ 用的是 context_storage.temporary_context(...) 这个上下文管理器把 span 临时压栈、退出即弹出 (decorator/generator_wrappers.py:122-127)。生成器的 yield 一交出控制权,栈就恢复原样了, 所以它没法在 yield 之外的时间当爹。span 本身也是懒创建的—— _ensure_span_and_trace_created 第一次被调用时才真造(generator_wrappers.py:57-66)。

为什么 stream 不能当爹?因为集成的 _streams_handler 一识别出 stream 对象, 第一件事就是 pop_end_candidates() 把 span 从栈里揪出来交给 stream 的补丁对象保管 (例:sdks/python/src/opik/integrations/anthropic/messages_create_decorator.py:118)。 span 已经不在栈上,自然当不了任何人的父。_tracked_sync 里对应的岔路是这段:

# 真实源码节选,base_track_decorator.py:353-359
stream_or_stream_manager = self._streams_handler(
result, track_options.capture_output, track_options.generations_aggregator,
)
if stream_or_stream_manager is not None:
return stream_or_stream_manager # 直接返回,不走 _after_call

命中 stream 分支就不调 _after_call——收尾权交给了 stream 的包装层。

3.3 上下文栈:为什么是 contextvars + 不可变 tuple

它要解决的小问题: 「当前 span 是谁」必须做到每个 asyncio task / 每个线程各看各的, 否则并发的两个请求会把彼此的 span 认成父子。

OpikContextStorage 的答案是 contextvarscontext_storage.py:41-54):四个 ContextVar, 分别存当前 trace、span 栈、当前 project 名、project 名的持有者。

关键设计是栈用不可变 tuple,不用 list。类 docstring 把这条规矩叫 create-new-set 模式 (context_storage.py:12-39):任何修改都是「读出旧 tuple → 拼出新 tuple → set 回去」。

# 真实源码,context_storage.py:121-123
def add_span_data(self, span: span.SpanData) -> None:
stack = self._spans_data_stack_context.get()
self._spans_data_stack_context.set(stack + (span,))

为什么必须这样? ContextVar 的隔离是引用级的:新 task 继承的是父 context 里那个同一个对象。 如果栈是 list,子 task 一 append,父 context 看到的那个 list 也变了——隔离直接漏掉。 tuple 不可变,+ (span,) 必然产生新对象,set() 只写进本 context,父 context 纹丝不动。 出栈同理用 stack[:-1]context_storage.py:111-113)。

3.4 挂给谁:create_span_respecting_context 的四条分支

它要解决的小问题: 新 span 有四种可能的归属:远端的父、本地栈顶的父、 一个手工建好的 trace、或者什么都没有(那就得自己开一个 trace)。

判断顺序如下(命中即停):

kwargs 里有 opik_distributed_trace_headers ?
├─ 是 ─▶ 挂到 headers 里的远端 parent_span_id / trace_id,trace_data=None
└─ 否

栈顶有 span ?
├─ 是 ─▶ 挂成它的子 span(继承 trace_id / project / environment)
└─ 否

context 里有 trace ?
├─ 是 ─▶ 建一个 parent_span_id=None 的 span,挂在这个 trace 下
└─ 否 ─▶ 新建 trace + 根 span(本 context 第一个被 track 的函数)

四条分支依次落在 decorator/span_creation_handler.py:78-86:88-125:127-154:156-177。 返回值统一是 SpanCreationResultspan_creation_handler.py:17-35)三元组:

字段含义什么时候不是空/真
trace_data本次新建的 trace只有第四条分支才非 None
span_data本次新建的 span永远有
should_process_span_data这个 span 要不要被压栈、上报只有第四条分支可能为 False

should_process_span_data 只在第四条分支上等于 should_create_duplicate_root_spanspan_creation_handler.py:179-183)。也就是说 @track(create_duplicate_root_span=False) 的意思是:根 span 和 trace 数据完全重复,那就别建根 span 了,只留 trace。 此时 add_start_candidates 跳过 add_span_data,span 对象被直接丢弃 (base_track_decorator.py:755-756)——如果用户还给这个根函数标了 type="llm""tool", SDK 会专门警告一句「你的 span type 要丢了」(_show_root_span_not_created_warning_if_neededbase_track_decorator.py:820-837)。

分支二、三里还有一条一致的规矩:父的 project_name 和 environment 无条件覆盖子的, 不一致时打警告(span_creation_handler.py:102-116:133-145)。唯一的例外是 created_by == "evaluation" 的 trace,此时静默不警告——评估框架自己会指定 project,属预期行为。

第二条分支的注释还留了个真实场景:栈里有 span 但没有 trace, 因为 trace 是在另一个线程建的(分布式),见 span_creation_handler.py:94-96 引用的 PR #2244

3.5 什么时候该连 trace 一起结束

它要解决的小问题: 每个函数返回都要弹一个 span,但 trace 只能收尾一次,而且 只有 @track 自己开的 trace 才轮得到它收尾——用户手工建的 trace、评估框架建的 trace 不能被误关。

答案是一个模块级的集合加两个自由函数:

# 真实源码,base_track_decorator.py:32
TRACES_CREATED_BY_DECORATOR: Set[str] = set()

add_start_trace_candidate 每建一个 trace 就把 id 塞进去(base_track_decorator.py:807), pop_end_candidate_trace_data 收尾时拿它当准入证:

# 真实源码节选,base_track_decorator.py:682-690
if (
context_storage.span_data_stack_empty()
and possible_trace_data_to_end is not None
and possible_trace_data_to_end.id in TRACES_CREATED_BY_DECORATOR
):
trace_data_to_end = context_storage.pop_trace_data(ensure_id=...)
TRACES_CREATED_BY_DECORATOR.discard(possible_trace_data_to_end.id)

三个条件缺一不可: 栈空了(说明最外层函数返回了)+ 确实有 trace + 这个 trace 是装饰器自己建的。 三者同时成立才连 trace 一起结束,否则只结 span。

pop_end_candidates()base_track_decorator.py:646-662)则是「弹一个 span, 顺手问一下要不要连 trace 一起弹」的组合拳,返回 (span_data, Optional[trace_data])。 它上面写着一句要紧的注释:弹出去的对象不能再当爹——因为它已经不在栈里了。 这就是 3.2 表里「stream 不能当父 span」的直接来源。

这两个函数是自由函数而不是方法,正因如此集成层可以直接 base_track_decorator.pop_end_candidates() 借用(integrations/openai/openai_chat_completions_decorator.py:145integrations/bedrock/converse/converse_decorator.py:108 等十余处)。

补充一个细节:TRACES_CREATED_BY_DECORATOR普通模块级 set,不是 ContextVar。 它存的只是 id 字符串,用作全局「这个 trace 归我管」的登记簿,跨 context 共享是有意为之 (比如 trace 在 A 线程建、在 B 线程结)。

3.6 异常不许打断用户代码

它要解决的小问题: 可观测性代码有 bug,绝不能把用户的业务代码带崩。 反过来,用户代码抛的异常也必须原样抛出去,不能被吞。

做法是一层 safe / 一层 unsafe 的成对包裹,共四个方法:

方法可见性职责
_before_callprotectedtry 包住 unsafe 版,出错只 LOGGER.error 并返回 False
__before_call_unsafe双下划线,名字改写真正干活:准备参数 → add_start_candidates
_after_callprotected同上,try 包住 unsafe 版,出错只记日志
__after_call_unsafe双下划线,名字改写真正干活:弹栈 → 填结束参数 → 交给 client
# 真实源码节选,base_track_decorator.py:436-451
try:
return self.__before_call_unsafe(...).should_process_span_data
except Exception as exception:
LOGGER.error(..., exc_info=True)
return False

用双下划线是为了名字改写(name mangling):子类(各集成)不可能不小心覆盖掉 unsafe 版, 只能通过安全外壳调用。类 docstring 也直说了「不建议覆写本类的其他方法」(base_track_decorator.py:53)。

另一半——用户异常的处理_tracked_sync 把用户函数的异常抓住、转成 error_info、把异常对象存进局部变量,等 span 收完尾再重抛

# 真实源码节选,base_track_decorator.py:340-369
try:
result = func(*args, **kwargs)
except Exception as exception:
error_info = error_info_collector.collect(exception)
func_exception = exception
...
self._after_call(output=result, error_info=error_info, ...)
if func_exception is not None:
raise func_exception

注意日志级别:Opik 自己的错用 LOGGER.error,用户函数的异常只用 LOGGER.debugbase_track_decorator.py:343)——后者本来就要重抛,用户自己会看到,SDK 不该在日志里刷屏。

第三道防线在参数准备阶段:_prepare_tracking_start_options 如果预处理器炸了, 就退化成一份「只有函数名 + type + tags」的最小 StartSpanParametersbase_track_decorator.py:226-241),追踪降级但不中断。


4. 深入实现

4.1 project 名的归属权:先到先得

问题: 嵌套的 @track 各自带了不同的 project_name,听谁的?

规则是先到先得,后来者被忽略并警告try_acquire_context_project_name(project_name, owner_id)context_storage.py:165-191)用两个 ContextVar 记「当前 project 名」和「持有者 id」: 持有者非空就直接返回 False,名字不一样时额外打一句警告。

释放严格按 id 配对:release_context_project_name_if_owner(owner_id) 只在持有者 id 匹配时才清空(context_storage.py:193-197)。谁拿的谁还,不会被内层误释放。

谁去申请?_try_acquire_project_namebase_track_decorator.py:699-713): 有 span 就用 span 的 id 当 owner,只有 trace(should_process_span_data=False 那条路)就用 trace 的 id。 释放点对称地落在 pop_end_candidatesbase_track_decorator.py:659)和 pop_end_candidate_trace_database_track_decorator.py:691-693)。

集成层读这个值靠 resolve_project_name(default, caller)context_storage.py:240-261): context 里的 project 名优先于集成初始化时传的 default,冲突时警告。

4.2 两个安全阀:给回调式集成擦屁股

LangChain、LlamaIndex、DSPy、ADK 这类框架是回调式的——on_start / on_end 是框架回调你, 中间可能因为异常、提前中断、并发乱序而丢掉 on_end。栈就会留下悬挂的 span。 OpikContextStorage 为此开了两个口子。

安全阀一:pop_span_data(ensure_id=...)——只弹我要的那个。

# 真实源码节选,context_storage.py:110-119
if ensure_id is None:
... # 无条件弹栈顶
if self.top_span_data().id == ensure_id:
return self.pop_span_data()
STACK_IS_EMPTY_OR_THE_ID_DOES_NOT_MATCH = None
return STACK_IS_EMPTY_OR_THE_ID_DOES_NOT_MATCH

栈顶不是预期那个就什么都不做,返回 None。宁可漏弹,不可错弹别人的 span。 pop_trace_data 有同款参数(context_storage.py:135-157)。

安全阀二:trim_span_data_stack_to_certain_span(span_id)——把栈截到指定 span 为止。context_storage.py:59-85)先检查这个 id 在不在栈里,不在就直接返回; 在的话从栈底往上重建,遇到目标 id 就停——目标之上的所有悬挂 span 全被砍掉。

两者常常配对使用:先 trim 把悬挂的砍掉,再 ensure_id 精确弹出自己那个。 LangChain 的 _process_end_span(起始 :580)收尾就是这个写法,finally 里统一调 _release_ended_span_state,那对 trim/pop 就写在它里面 (integrations/langchain/opik_tracer.py:640-663);出错路径 _process_end_span_with_error (起始 :697)的 finally 一字不差地重复同一动作(同文件 :743-745)。 LangChain 还有一招更狠的 _ensure_no_hanging_opik_tracer_spans:如果 chain 调用之前栈本来就是空的, 直接 clear_spans() 清干净;否则 trim 回到调用前的那个外部 span (integrations/langchain/opik_tracer.py:307-319)。

4.3 两个上下文管理器

名字位置干什么典型用户
project_context(project_name)context_storage.py:268-290一个 with 块内的所有 Opik 操作都归到指定 project用户;由 opik.project_context 导出
temporary_context(span_data, trace_data)context_storage.py:293-320临时把 span/trace 塞进 context,退出即还原生成器包装器

project_context 内部就是 acquire / release 那一对,用 f"project_context_{id(project_name)}_{id(object())}" 造一个唯一 owner id, 且只有真的抢到了才释放acquired 标志,context_storage.py:285-290)。

temporary_context 的还原动作是三件事:恢复原 trace、reset project 名的 token、弹掉 span (context_storage.py:317-320)。注意它用的是低层的 _raw_set_context_project_name / _raw_reset_context_project_namecontext_storage.py:199-207)——走 ContextVar 的 token 存档/还原,而不是走归属权那套,因为它要的是无条件还原而不是先到先得。

4.4 用户侧 API:opik_context

函数干什么拿不到时的行为
get_current_span_data()读当前 span返回 None
get_current_trace_data()读当前 trace返回 None
update_current_span(...)往当前 span 上补 name/output/usage/model/feedback_scores…OpikException
update_current_trace(...)同上,作用于 trace,可设 thread_idOpikException
get_distributed_trace_headers()导出 {opik_trace_id, opik_parent_span_id} 给远端OpikException
attach_prompt_to_current_span(p)把 prompt 记进 span metadata静默返回

一个必须知道的坑:get_current_span_data() 返回的是拷贝,不是本体。

# 真实源码,opik_context.py:25-29
span_data = context_storage.top_span_data()
if span_data is None:
return None
return span.SpanData(**span_data.__dict__)

所以你改这个返回值不会影响真正被上报的 span——想改必须用 update_current_span(), 它拿的是 top_span_data() 本体再 .update()opik_context.py:118-122)。

get_distributed_trace_headers() 就是把当前 span 的 trace_id 和自己的 id 打包 (opik_context.py:53-56);远端把它作为 opik_distributed_trace_headers 关键字塞给被 track 的函数, 就接回 3.4 的第一条分支。取值方是 extract_distributed_trace_headers—— 用的是 kwargs.pop把这个关键字从参数里摘掉再交给用户函数decorator/arguments_helpers.py:106-109),所以用户函数签名里不用声明它。

attach_prompt_to_current_span 和 trace 版共用 _attach_prompt_to_observationopik_context.py:203-216),按 (id, version.commit) 二元组去重后追加到 metadata 的 opik_prompts 列表里——同一个 prompt 在同一 span 里反复引用不会堆重复项。 所有写操作前都先查 is_tracing_active(),关了就直接 return(如 opik_context.py:97-98)。

4.5 周边零件各补什么

模块补的是关键符号
decorator/arguments_helpers.py三个数据类(TrackOptions 存装饰器参数、StartSpanParameters / EndSpanParameters 存两端的 span 字段)+ create_span_data 真正 new 出 SpanData + extract_distributed_trace_headersTrackOptions:60create_span_data:81
decorator/opik_args/「不改函数体就能给这次调用加 tag / metadata / thread_id」的旁路通道extract_opik_argsapply_opik_args_to_trace
decorator/generator_wrappers.py生成器的懒建 span、逐值累积、结束时聚合SyncTrackedGenerator_try_aggregate_items
decorator/error_info_collector.py把异常压成 {exception_type, traceback, message?} 三字段collect
decorator/inspect_helpers.py(args, kwargs) 反推出参数名字典、判 async、取函数名extract_inputsis_async

opik_args 值得单说。 它让调用方在调用点而不是定义点加元数据:

# 示意,非源码
answer("hi", opik_args={"trace": {"thread_id": "conv-42", "tags": ["vip"]}})

extract_opik_args 会先看函数签名里有没有 opik_args 这个形参:有就 kwargs.get(留给用户), 没有就 kwargs.pop(摘掉,免得用户函数收到不认识的参数报 TypeError) (decorator/opik_args/helpers.py:31-44)。 tags 和 metadata 是合并而不是覆盖(helpers.py:113-150:107-111); thread_id 若与已有值冲突则保留原值并警告(helpers.py:88-110)。 trace_args.id 还能预设 trace id,一路传到 create_span_respecting_contextpreset_trace_idbase_track_decorator.py:744-747span_creation_handler.py:160)。

生成器的输出聚合: 没给 generations_aggregator 就默认把所有 yield 值 str() 后拼接; 给了但聚合函数自己炸了,就退回 str(items) 并记 error 日志 (generator_wrappers.py:175-192)。又是一次「宁可降级不可中断」。


5. 十七个集成怎么接进来:两个钩子 + 两种范式

sdks/python/src/opik/integrations/ 下有 17 个顶层包(adk、agentspec、aisuite、anthropic、 bedrock、crewai、dspy、genai、guardrails、harbor、haystack、langchain、litellm、 llama_index、openai、otel、sagemaker)。它们不是同一种接法——分两派:

范式怎么接代表为什么
装饰器派继承 BaseTrackDecorator,只实现三个抽象方法,然后猴补丁替换 SDK 方法anthropic、openai、bedrock、genai、litellm、crewai、aisuite、guardrails、harbor目标 SDK 是同步/异步函数调用,能直接包
回调派不继承任何东西,直接操作 OpikContextStoragelangchain、llama_index、dspy、adk、haystack、otel、agentspec目标框架是回调 / tracer 协议,只给你 on_start / on_end 钩子

(依据:grep -rl BaseTrackDecorator sdks/python/src/opik/integrations/ 只命中装饰器派那几个包; 回调派的入口文件叫 opik_tracer.py / callback.py / processor.py。) 回调派用的正是 4.2 那两个安全阀,因为它们随时可能丢掉 on_end。

装饰器派要填的三个空

子类只需要覆写这三个抽象方法,span 树怎么长、什么时候结束这些事一行都不用写

抽象方法什么时候被调要返回什么
_start_span_inputs_preprocessor函数调用前StartSpanParameters:从 (args, kwargs) 里挑出哪些算 input、哪些算 metadata
_end_span_inputs_preprocessor函数返回后EndSpanParameters:从返回值里挑出 output、usage、model
_streams_handler拿到返回值后认出 stream 就返回打了补丁的对象,认不出返回 None

(定义在 base_track_decorator.py:575-620。)

一个真实例子:Anthropic。 AnthropicMessagesCreateDecoratorintegrations/anthropic/messages_create_decorator.py:22):

  • 起始钩子按一份白名单 ["messages", "system", "tools", "output_format"] 把 kwargs 劈成 input 和 metadata,再补 created_from: "anthropic"tags=["anthropic"]messages_create_decorator.py:18:45-60)。
  • 结束钩子从 output.usage 提 token 用量,交给 llm_usage.try_build_opik_usage_or_log_error 统一成 Opik 的用量格式;content 字段算 output,其余进 metadata (messages_create_decorator.py:77-104)。
  • stream 钩子逐个 isinstance 判六种流对象(MessageStreamManagerStream、异步版、 Beta 版…),每种都先 pop_end_candidates() 拿到 span,再交给 stream_patchers 在流耗尽时回调 self._after_callmessages_create_decorator.py:108-181)。

一个文档陷阱: 类 docstring 第 51 行写「必须实现 _generators_handler」, 但代码里真正的抽象方法叫 _streams_handlerbase_track_decorator.py:576)—— docstring 没跟上改名。以代码为准。


6. 巧妙之处(可以直接借鉴的)

  1. 不可变 tuple 当栈,把 ContextVar 的隔离从「引用级」提升到「值级」。 一行 stack + (span,) 换来并发正确性,比加锁便宜得多(context_storage.py:121-123)。

  2. safe / unsafe 成对包裹 + 双下划线名字改写。 可观测性代码的 bug 只写日志不上抛, 而且子类想覆写都覆写不到(base_track_decorator.py:436-451:486-502)。

  3. 「span 在不在栈里」当成唯一的抽象轴。 四种跟踪策略的差别不用四套代码解释, 一句「它在栈里待多久」就讲完了(类 docstring base_track_decorator.py:155-174)。

  4. ensure_id 语义:宁可漏弹,不可错弹。 给不可靠的回调式调用方一个安全的弹栈原语 (context_storage.py:93-119)。

  5. 归属权按 owner id 配对释放。 project 名不用栈也不用计数器, 靠「谁抢到谁还」实现正确的嵌套语义(context_storage.py:165-197)。

  6. opik_args 的 pop / get 二选一。 看函数签名决定是摘掉还是留下这个关键字参数, 既支持旁路传参又不破坏用户函数的签名契约(decorator/opik_args/helpers.py:31-44)。


7. 边界与局限

  • stream span 天生是叶子。 它一被 _streams_handler 弹出栈就当不了父, 流式 LLM 调用内部的任何嵌套追踪都挂不上去(base_track_decorator.py:646-653 的注释直说了)。

  • 生成器 span 只在 __next__ 里当爹。 生成器体内 yield 之外的耗时, Opik 记不到正确的父子关系(类 docstring base_track_decorator.py:162-168)。

  • 追踪开关只在调用开始时读一次。 中途 set_tracing_active(False) 拦不住已经开始的调用, 这是明确写进 docstring 的既定行为(base_track_decorator.py:108-110)。

  • 嵌套 @trackproject_name / environment 一律被外层覆盖,只有警告没有报错 (span_creation_handler.py:53-65:102-116)。想分 project 就别嵌套,用 project_context 在最外层圈定。

  • create_duplicate_root_span=False 会静默丢掉根 span 的 type, 只在 type 是 llm / tool 时才警告一句(base_track_decorator.py:828-837)。

  • temporary_context 的 finally 假定 try 的前两句不会抛。 original_traceproject_token 都在 try 内部赋值,若 set_trace_data 抛异常, finally 里会撞上未绑定变量(context_storage.py:301-320)。实践中几乎不可能发生, 但这段代码不是防御式的。

  • 本章不管发送。 span/trace 对象交给 client.__internal_api__span__ 之后的事—— 批量、限流、断线续传——全在第 2 章


8. 横向对比与后续阅读

同 shelf 的其他可观测项目大多把 span 语义直接绑在 OpenTelemetry 上;Opik 的取舍是 先有自己的一套内存模型(SpanData / TraceData + 影子栈),再把 otel 当成众多集成之一integrations/otel/ 只有 processor 和属性映射,不继承 BaseTrackDecorator)。 好处是生成器 / stream 这类 Python 特有形态可以有专门的策略,代价是要自己维护上下文传播。


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

主题文件路径符号名
装饰器骨架、四种策略分派sdks/python/src/opik/decorator/base_track_decorator.pyBaseTrackDecorator_decorate
裸用 / 带参两种形态sdks/python/src/opik/decorator/base_track_decorator.pyBaseTrackDecorator.track
前后钩子的安全包裹sdks/python/src/opik/decorator/base_track_decorator.py_before_call_after_call
trace 归属登记簿sdks/python/src/opik/decorator/base_track_decorator.pyTRACES_CREATED_BY_DECORATOR
开始 / 结束候选的进出栈sdks/python/src/opik/decorator/base_track_decorator.pyadd_start_candidatespop_end_candidatespop_end_candidate_trace_data
默认装饰器实现,opik.track 本体sdks/python/src/opik/decorator/tracker.pyOpikTrackDecoratortrack
contextvars 影子栈sdks/python/src/opik/context_storage.pyOpikContextStorageadd_span_data
回调式集成的两个安全阀sdks/python/src/opik/context_storage.pypop_span_datatrim_span_data_stack_to_certain_span
project 名先到先得sdks/python/src/opik/context_storage.pytry_acquire_context_project_namerelease_context_project_name_if_owner
两个上下文管理器sdks/python/src/opik/context_storage.pyproject_contexttemporary_context
新 span 挂给谁的四条分支sdks/python/src/opik/decorator/span_creation_handler.pycreate_span_respecting_contextSpanCreationResult
用户侧读写 APIsdks/python/src/opik/opik_context.pyget_current_span_dataupdate_current_spanget_distributed_trace_headers
参数数据类与分布式头提取sdks/python/src/opik/decorator/arguments_helpers.pyTrackOptionsextract_distributed_trace_headers
调用点旁路参数sdks/python/src/opik/decorator/opik_args/helpers.pyextract_opik_argsapply_opik_args_to_trace
生成器的懒建与聚合sdks/python/src/opik/decorator/generator_wrappers.pySyncTrackedGenerator_try_aggregate_items
异常压缩成三字段sdks/python/src/opik/decorator/error_info_collector.pycollect
装饰器派集成的样板sdks/python/src/opik/integrations/anthropic/messages_create_decorator.pyAnthropicMessagesCreateDecorator
回调派集成的样板sdks/python/src/opik/integrations/langchain/opik_tracer.py_process_end_span_ensure_no_hanging_opik_tracer_spans
手工版 with 语法sdks/python/src/opik/decorator/context_manager/span_context_manager.pystart_as_current_span