跳到主要内容

手动埋点:workflow / task / agent / tool 装饰器与上下文传播

30 秒导读: 第 2 章讲的是 SDK 怎么自动拦住 OpenAI/LangChain 这类被 instrument 的库。 但你自己写的业务函数(一个"检索 → 拼 prompt → 调模型 → 后处理"的流程)对 SDK 是黑盒。 本章讲的就是:用 @workflow / @task / @agent / @tool / @conversation 这几个装饰器, 手动给自己的代码"织"出一棵结构化的 trace——谁是谁的父节点、每一步的入参出参是什么、 这些 span 又怎么自动串成一条链。


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

一句话定义: 一组 Python 装饰器,贴在你自己的函数上,函数一被调用就自动开一个 OpenTelemetry span, 函数返回就关掉;span 上还带着"这是 workflow 还是 tool、入参出参是什么、属于哪条业务链"的语义标签。

它解决什么问题。 自动埋点只认得它 hook 过的第三方库。但一个真实的 AI 应用里, "业务逻辑"这层是你自己写的——比如:

handle_chat(问题)
├─ retrieve(问题) ← 你写的检索函数
├─ build_prompt(片段) ← 你写的拼装函数
└─ openai.chat(...) ← 第三方库,自动埋点能抓到

自动埋点只能给最后那行 openai.chat 建 span,前面两步在 trace 里完全是空白。 你看到一个孤零零的 LLM 调用,却不知道它属于哪个流程、检索花了多久、拼进去的上下文是什么。 手动装饰器补的就是这段空白:给 handle_chat / retrieve / build_prompt 各自建一个 span, 并让它们自动挂成父子结构。

五个装饰器,各自表达一种业务语义(区别只在 span 上标的"种类"不同):

装饰器span kind典型用途
@workflowworkflow一整条端到端流程的根
@tasktask流程里的一个步骤
@agentagent一个自主决策的 agent
@tooltoolagent 调用的一个工具
@conversation(不建 span)给一条链打上会话 ID

用起来什么样(一个最小示例):

from traceloop.sdk import Traceloop
from traceloop.sdk.decorators import workflow, task

Traceloop.init(app_name="my-app") # 见第 1 章:启动 SDK

@task(name="retrieve") # 这一步会成为一个子 span
def retrieve(question: str) -> list[str]:
return vector_db.search(question)

@workflow(name="chat") # 这是整条链的根 span
def handle_chat(question: str) -> str:
docs = retrieve(question) # 自动挂在 chat 之下,变成 chat → retrieve
return llm.chat(question, docs) # 第三方 LLM span 也自动挂进来

跑一次 handle_chat(...),你在后端看到的 trace 就是一棵树:chat.workflow 下面挂着 retrieve.task,再下面挂着自动埋点抓到的 openai.chat——结构、耗时、入参出参一应俱全

一句话直觉: 把装饰器想成"给函数戴计时手环"。手环在函数进门时开始记(开 span、记入参), 出门时停下(关 span、记出参),还会自动把"我是谁的下属"这条信息悄悄递给屋里所有人—— 这"悄悄递"就是本章后半段的上下文传播


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

2.1 一张图:五个入口,一个核心

先记住这张图的读法:从上到下是"越来越薄的委派",真正干活的只有最底下的 entity_method

用户代码贴的装饰器 decorators/__init__.py 真正的实现
───────────────── ──────────────────── ──────────────
@workflow ─┐
@task ─┤ (只是设定 span kind, base.py
@agent ─┼── 然后原样转手) ───────────► entity_method ← 装饰单个函数
@tool ─┘ entity_class ← 装饰整个类的某方法
@conversation ──(特例:不建 span, (entity_class 内部也是调 entity_method)
只往 context 塞 conversation_id)
  • @workflow / @task 各自带一个默认 tlp_span_kind,把它连同 name/version 透传给 entity_method
  • @agent 干脆复用 @workflow,只是把 span kind 换成 AGENT; @tool 同理复用 @task,换成 TOOL
  • 所以五个装饰器最终只有一个真正的实现体:entity_method。理解它就理解了全部。

2.2 部件一句话职责

部件干什么在哪
workflow/task/agent/tool/conversation面向用户的门面,设定 span kind 后委派decorators/__init__.py:34-130
atask/aworkflow/aagent/atool废弃的异步版,只多打一句 DeprecationWarningdecorators/__init__.py:134-215
entity_method核心:按同步/异步/生成器分四条路,起停 spandecorators/base.py:205
entity_class把类的某个方法换成被 entity_method 包过的版本decorators/base.py:286
_setup_span开 span、写 span kind / entity.name、拼 entity_pathdecorators/base.py:138
_handle_span_input/_handle_span_output把入参出参 JSON 化写进 span(受内容开关控制)decorators/base.py:169-196
context 存取(set_workflow_name 等)用 OTel context 存 workflow/agent/entity_path,实现传播tracing/tracing.py:263-293
ContentAllowList全局关内容时,按 association 属性放行白名单tracing/content_allow_list.py:3

2.3 主线走一遍(高层)

一次被装饰函数的调用,大致经历:

调用 f(args)

├─ 1. verify_initialized()? 没 init 就直接跑原函数,不埋点 base.py:260
├─ 2. _setup_span:开 span、attach 到 context、写语义属性 base.py:138
├─ 3. _handle_span_input:入参 JSON 化写进 span(若允许内容) base.py:169
├─ 4. 真正执行原函数 fn(args)
│ └─ 期间它调用的任何子函数/LLM,都能从 context 读到"我在 f 之下"
├─ 5. _handle_span_output:出参 JSON 化写进 span base.py:185
└─ 6. _cleanup_span:span.end() + 从 context detach base.py:199

第 4 步里那句"子函数能读到我在 f 之下",就是整章的暗线——见 §4 上下文传播。


3. 核心机制之一:五个门面如何收敛到一个实现

它要解决的小问题: 五种业务语义(workflow/task/agent/tool)在 trace 里只差一个"种类"标签, 没必要写五套包装逻辑。

思路: 门面层只负责"决定 span kind",实现层 entity_method 负责"所有起停 span 的脏活"。

3.1 task / workflow:带默认 span kind 的透传

taskworkflow 几乎一模一样,只是默认 tlp_span_kind 不同。看 workflow:

# decorators/__init__.py:90 workflow
def workflow(name=None, version=None, method_name=None,
tlp_span_kind=TraceloopSpanKindValues.WORKFLOW):
if method_name is None:
return entity_method(name=name, version=version, tlp_span_kind=tlp_span_kind)
else:
return entity_class(name=name, version=version,
method_name=method_name, tlp_span_kind=tlp_span_kind)

一个分叉:没给 method_name → 装饰函数(entity_method);给了 → 装饰类的那个方法(entity_class)。 task 的代码结构与此逐字对应,只是默认 kind 是 TASK(decorators/__init__.py:73)。

3.2 agent / tool:直接复用,只换 kind

agenttool 更省——它们连门面都不自己写,直接调 workflow / task 换个 kind:

# decorators/__init__.py:107 agent → 复用 workflow,kind 换成 AGENT
def agent(name=None, version=None, method_name=None):
return workflow(name=name, version=version, method_name=method_name,
tlp_span_kind=TraceloopSpanKindValues.AGENT)

# decorators/__init__.py:120 tool → 复用 task,kind 换成 TOOL
def tool(name=None, version=None, method_name=None):
return task(..., tlp_span_kind=TraceloopSpanKindValues.TOOL)

四个 span kind 的取值来自枚举 TraceloopSpanKindValues (opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py:373):

枚举成员字符串值
WORKFLOW"workflow"
TASK"task"
AGENT"agent"
TOOL"tool"

这个字符串最终会原样写进 span 的 traceloop.span.kind 属性(见 §5)。

3.3 conversation:唯一不建 span 的特例

@conversation 不是"埋一个 span",而是"给这条链贴一个会话 ID"。它自己判断同步/异步, 在调用原函数把 ID 写进 context:

# decorators/__init__.py:56 conversation 的 decorator
def decorator(fn):
if inspect.iscoroutinefunction(fn):
async def async_wrapper(*args, **kwargs):
set_conversation_id(conversation_id) # 只往 context 塞值
return await fn(*args, **kwargs)
return async_wrapper
else:
def sync_wrapper(*args, **kwargs):
set_conversation_id(conversation_id)
return fn(*args, **kwargs)
return sync_wrapper

set_conversation_id 把值存进 OTel context,之后这条链上所有 span在开始时都会读到它、 写成 gen_ai.conversation.id 属性(tracing/tracing.py:271,消费点见 §4.2)。所以它常和 @workflow 叠着用:@workflow 建根 span,@conversation 给整棵树盖上会话戳。

3.4 废弃的 a* 异步版:只多一句警告

早期同步和异步要分开用 @task / @atask。现在 entity_method 已能同时处理两者(见 §4), 所以 atask/aworkflow/aagent/atool 全部废弃——它们的实现就是"打一句 DeprecationWarning 再转调同步版":

# decorators/__init__.py:134 atask(节选)
warnings.warn(
"DeprecationWarning: The @atask decorator will be removed ... migrate to @task ...",
DeprecationWarning, stacklevel=2,
)
# ……随后逻辑与 task 完全相同

要点: 新代码一律用不带 a 前缀的版本;@task 装同步函数还是异步函数都行,底层自动分流。


4. 核心机制之二:entity_method 的四条包装路径

这是全章的心脏。entity_method 要面对一个现实:被装饰的函数可能是四种形态之一, 起停 span 的时机各不相同。它在装饰时(decorate)就一次性判好类型,选对包装器。

4.1 为什么要分四条路

关键在**"span 什么时候该 end"**:

  • 普通函数:return 时就结束了 → span 在 return 后 end。
  • 生成器函数(yield):函数体返回时还没真正跑,要等消费者把 yield 全部取完才算完 → span 必须在迭代耗尽时才 end,不能在拿到生成器对象时就 end。

同步/异步 × 普通/生成器,四种组合,四条路:

┌─ 普通协程 → async_wrap (await 完 → end) base.py:235
是异步? ── 是 ──┤
└─ 异步生成器 → async_gen_wrap (async for 耗尽 → end) base.py:214
── 否 ──┐
└─ 同步:sync_wrap;若返回值是 generator,交给 _handle_generator base.py:258

判定异步的辅助函数把"协程"和"异步生成器"都算作异步:

# decorators/base.py:133 _is_async_method
def _is_async_method(fn):
return inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn)

4.2 同步路径:一条最完整的生命周期

同步 sync_wrap 最能看清完整流程(decorators/base.py:258-281):

def sync_wrap(*args, **kwargs):
if not TracerWrapper.verify_initialized(): # 没 init:退化成裸调用,不埋点
return fn(*args, **kwargs)

span, ctx, ctx_token = _setup_span(entity_name, tlp_span_kind, version)
_handle_span_input(span, args, kwargs, cls=JSONEncoder)
try:
res = fn(*args, **kwargs)
except Exception as e:
span.set_status(Status(StatusCode.ERROR, str(e))) # 异常:标红 + 记录
span.record_exception(e)
_cleanup_span(span, ctx_token)
raise
if isinstance(res, types.GeneratorType): # 返回的是生成器 → 交给它去 end
return _handle_generator(span, res)
_handle_span_output(span, res, cls=JSONEncoder)
_cleanup_span(span, ctx_token)
return res

三个值得记住的点:

  1. 未初始化即透明退化——没调 Traceloop.init() 时,装饰器等于不存在,直接跑原函数。 四条路的开头都有这道 verify_initialized() 闸(base.py:218/237/260)。
  2. 异常一定被记录再抛——set_status(ERROR) + record_exception,然后照常 raise, 不吞异常。四条路对异常的处理一致。
  3. 生成器的 span 不在这里 end——发现返回值是生成器,就把 span 交给 _handle_generator, 自己不 end(见 §4.4)。

entity_name 的取值也在这层定好:name or fn.__qualname__(base.py:212)—— 你不给 name 就用函数的限定名。

4.3 _setup_span:span 是怎么"配置"出来的

_setup_span 是入口处的关键(decorators/base.py:138-166),它做四件事:

① 按 span kind 往 context 写"名字"。 只有 workflow/agent 会写:

# decorators/base.py:140
if tlp_span_kind == TraceloopSpanKindValues.WORKFLOW:
set_workflow_name(entity_name)
elif tlp_span_kind == TraceloopSpanKindValues.AGENT:
set_agent_name(entity_name)

这两句是传播的源头——它把当前 workflow/agent 名字存进 context,后面所有子 span 都能读到(§4.5)。

② 开 span 并 attach 到当前 context。 span 名是 f"{entity_name}.{kind}", 比如 chat.workflowretrieve.task:

# decorators/base.py:145
span_name = f"{entity_name}.{tlp_span_kind.value}"
with get_tracer() as tracer:
span = tracer.start_span(span_name)
ctx = trace.set_span_in_context(span)
ctx_token = context_api.attach(ctx) # 之后新 span 会以它为 parent

③ 只有 task/tool 拼 entity_path。 这是父子链路径(a.b.c 形式):

# decorators/base.py:152
if tlp_span_kind in [TraceloopSpanKindValues.TASK, TraceloopSpanKindValues.TOOL]:
entity_path = get_chained_entity_path(entity_name)
set_entity_path(entity_path)

④ 写语义属性。 span kind、entity 名、可选的 version;tool 额外写 gen_ai.tool.name:

# decorators/base.py:159
span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND, tlp_span_kind.value) # traceloop.span.kind
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, entity_name) # traceloop.entity.name
if tlp_span_kind == TraceloopSpanKindValues.TOOL:
span.set_attribute(GEN_AI_TOOL_NAME, entity_name) # gen_ai.tool.name
if version:
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_VERSION, version) # traceloop.entity.version

注意 workflow/agent 名字写在哪。 _setup_span 只把它们存进 context,并没直接写进本 span 的属性。真正写成 traceloop.workflow.name / gen_ai.agent.name 属性的地方,是 span processor 的 on_start 回调(tracing/tracing.py:369 default_span_processor_on_start),它在每个 span 开始时从 context 读这些值并落属性——这样子 span 也能带上父 workflow/agent 名。语义约定细节见 第 3 章

4.4 生成器路径:span 的生命周期跟着迭代走

同步生成器交给 _handle_generator(decorators/base.py:94-108)。它把 span 的 end 推迟到迭代真正结束:

def _handle_generator(span, res):
context_api.attach(trace.set_span_in_context(span)) # 生成器里 context 会丢,重新设一次
try:
for item in res:
yield item
except Exception as e:
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
raise
finally:
span.end() # 只在迭代耗尽(或异常)后才结束 span

两处坑点值得记:

  • 生成器不记录 output。 走生成器路径时没有 _handle_span_output 调用——因为值是流式吐出的, 没有单一"返回值"可序列化。异步生成器 async_gen_wrap(base.py:214)同理。
  • 同步生成器不 detach context。 注意 _handle_generatorfinally 里只有 span.end(), 没有 context_api.detach;源码注释指向 opentelemetry-python#2606, 说明在某些场景 detach 会失败,靠 GC 兜底。而异步生成器 _ahandle_generator(base.py:111) 的 finallycontext_api.detach(ctx_token) 的——这是一处刻意的同步/异步不对称。

4.5 上下文传播:span 之间怎么"自动认亲"

到这里回答本章的暗线:为什么子函数的 span 会自动挂到父函数之下,还带着父 workflow 的名字?

机制一:OTel context 的 attach + set_value,只进不出。 所有"名字"类信息都存在 OTel context 里:

# tracing/tracing.py:263
def set_workflow_name(workflow_name):
attach(set_value("workflow_name", workflow_name)) # 存进 context,不保留 detach token

def set_agent_name(agent_name): # tracing/tracing.py:267
attach(set_value("agent_name", agent_name))

def set_entity_path(entity_path): # tracing/tracing.py:284
attach(set_value("entity_path", entity_path))

注意这几个 attach不保存 token、不 detach——值一旦设进去,就沿着这条执行链一直有效, 子函数、子 span 都能读到。这就是"父 workflow 名字自动传给所有后代"的原理。

机制二:span attach 建立父子。 _setup_spancontext_api.attach(ctx) 把当前 span 设为 "context 里的活动 span";子函数再开 span 时,OTel 自动拿 context 里的活动 span 当 parent。 一层层 attach,就织出了树。

机制三:get_chained_entity_path 拼层级路径。 task/tool 的 entity_path 是这样滚雪球的:

# tracing/tracing.py:288
def get_chained_entity_path(entity_name):
parent = get_value("entity_path") # 读父级已有的路径
if parent is None:
return entity_name # 顶层:就是自己
else:
return f"{parent}.{entity_name}" # 深层:父路径.自己

于是嵌套的 task 会得到 outer.inner.leaf 这样的路径,写进 traceloop.entity.path。 一张小图看清三种"名字"怎么随调用下沉:

@workflow chat context: workflow_name="chat"
└─ @task retrieve context: workflow_name="chat", entity_path="retrieve"
└─ @task rerank context: workflow_name="chat", entity_path="retrieve.rerank"

每个子 span 起始时,default_span_processor_on_start 从 context 把这些值读出来落到属性上 (tracing/tracing.py:373-391):workflow_nametraceloop.workflow.nameagent_namegen_ai.agent.nameconversation_idgen_ai.conversation.identity_pathtraceloop.entity.pathassociation_properties→逐个展开。


5. 核心机制之三:入参出参的记录与内容开关

它要解决的小问题: trace 里想看到"这一步喂进去什么、吐出来什么",但这些内容可能含敏感数据, 得能一键关掉、还能对超长值截断。

5.1 入参出参:JSON 序列化后写进 span

_handle_span_inputargs/kwargs 打包成 JSON 写进 traceloop.entity.input; _handle_span_output 把返回值写进 traceloop.entity.output:

# decorators/base.py:169 _handle_span_input
def _handle_span_input(span, args, kwargs, cls=None):
try:
if _should_send_prompts(): # 内容开关:关了就整段跳过
json_input = json.dumps({"args": args, "kwargs": kwargs},
**({"cls": cls} if cls else {}))
truncated_json = _truncate_json_if_needed(json_input) # 超长截断
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT, truncated_json)
except TypeError:
pass # 序列化不了(如含无法编码的对象)就静默放弃,绝不让埋点搞崩业务

三个细节:

  • 自定义编码器 JSONEncoder(utils/json_encoder.py)——能把 dataclass、pydantic 模型 (model_dump/dict/to_json)等非原生对象转成可序列化结构,还会顺手删掉 callbacks 键, 尽量让业务对象也能入 trace。
  • except TypeError: pass——序列化失败绝不抛错。埋点是旁路,不能因为记不下入参就让主流程崩。
  • output 路径 _handle_span_output(base.py:185)结构对称,写的是 traceloop.entity.output

5.2 内容开关 _should_send_prompts

是否记录入参出参,由一个开关统一控制(decorators/base.py:124):

def _should_send_prompts():
return (
os.getenv("TRACELOOP_TRACE_CONTENT") or "true"
).lower() == "true" or context_api.get_value("override_enable_content_tracing")

读法:

  • 默认开(环境变量缺省视为 "true")。设 TRACELOOP_TRACE_CONTENT=false 可全局关闭内容采集, 此时 §5.1 里的 if 整段跳过,span 上不再有入参出参。
  • 即使全局关了,只要 context 里 override_enable_content_tracing 为真,仍会记录——这就是 白名单机制的挂钩点(§6)。

5.3 超长截断

_truncate_json_if_needed(decorators/base.py:35)按环境变量 OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT 把过长的 JSON 硬切:

limit_str = os.getenv("OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT")
if limit_str:
limit = int(limit_str)
if limit > 0 and len(json_str) > limit:
return json_str[:limit] # 直接截断,可能产出非法 JSON——这是刻意的

源码 docstring 明说:截断后可能是非法 JSON,这对"仅用于日志/观测"是可接受的取舍。


6. 核心机制之四:association 属性与内容白名单

它要解决的小问题: 想给一整条链打业务标签(哪个用户、哪个会话),用于后端筛选; 同时,在"全局关内容"的前提下,又想对特定用户/会话的内容单独放行采集。

6.1 打标签:set_association_propertiesAssociations.set

set_association_properties(tracing/tracing.py:247)把一个 dict 存进 context, 并立即给当前 span 补上属性(如果它是 workflow/task):

def set_association_properties(properties):
attach(set_value("association_properties", properties)) # 存 context,供后代 span 用
span = trace.get_current_span()
if get_value("workflow_name") is not None or get_value("entity_name") is not None:
_set_association_properties_attributes(span, properties) # 回填当前 span

每个键会展开成 traceloop.association.properties.<key>(tracing/tracing.py:256)。 后续每个子 span 起始时,default_span_processor_on_start 也会把 context 里的 association 属性 逐个落上去(tracing/tracing.py:389),所以整条链的 span 都带同一批标签。

面向用户有个更规整的封装 Associations.set(associations/associations.py:21), 用枚举 AssociationProperty(CUSTOMER_ID/USER_ID/SESSION_ID)约束键名:

# 示意,非源码用法
from traceloop.sdk.associations.associations import Associations, AssociationProperty
Associations.set([(AssociationProperty.USER_ID, "user-456"),
(AssociationProperty.SESSION_ID, "session-789")])

它内部就是把元组转成 dict 再调 set_association_properties(associations/associations.py:40)。

6.2 内容白名单:全局关闭下的定点放行

ContentAllowList(tracing/content_allow_list.py:3)是个单例,存一份"允许采集内容的 association 属性组合"。它和 §5.2 的内容开关串在一起:

# tracing/tracing.py:189 TracerWrapper._span_processor_on_start(节选)
association_properties = get_value("association_properties")
if association_properties is not None:
if not self.enable_content_tracing: # 全局关了内容
if self.__content_allow_list.is_allowed(association_properties):
attach(set_value("override_enable_content_tracing", True)) # 白名单命中→放行
else:
attach(set_value("override_enable_content_tracing", False))

is_allowed(content_allow_list.py:11)的判断是"当前 association 属性是否完全匹配白名单里 某一条(该条里的每个 key/value 都对得上)":

def is_allowed(self, association_properties):
for allow_list_item in self._allow_list:
if all(association_properties.get(key) == value
for key, value in allow_list_item.items()):
return True
return False

串起来看整条控制流:

全局 enable_content_tracing = False (默认想关内容)

span 开始,读 association_properties(如 {user_id: "vip-1"})

ContentAllowList.is_allowed? ──命中──► override_enable_content_tracing = True
│ │
└── 未命中 ──► override = False ▼
_should_send_prompts() 返回 True
→ 这条链的入参出参照常记录

白名单本身由 SDK 从后端拉取的配置 load 进来(content_allow_list.py:23,键 associationPropertyAllowList)。效果:平时对所有流量关闭内容采集,只对被点名的用户/会话 (如调试中的 VIP、报障会话)采集内容,兼顾隐私与可观测。


7. 旁支:手动包一个 LLM span(manual.py)

装饰器是"给一整个函数"埋点。但有时你想手动为一次 LLM 调用建 span,并按 GenAI 语义 自己填 request/response/usage——这就是 tracing/manual.pytrack_llm_call:

# tracing/manual.py:75
@contextmanager
def track_llm_call(vendor: str, type: str):
with get_tracer() as tracer:
span = tracer.start_span(name=f"{vendor}.{type}")
span.set_attribute(GenAIAttributes.GEN_AI_SYSTEM, vendor)
span.set_attribute(SpanAttributes.LLM_REQUEST_TYPE, type)
token = context.attach(set_span_in_context(span))
try:
yield LLMSpan(span) # 用户拿它调 report_request/response/usage
finally:
context.detach(token)
span.end()

LLMSpan(manual.py:26)提供 report_request / report_response / report_usage, 把消息、补全、token 用量按 GenAI 约定写成属性(gen_ai.prompt.* / gen_ai.completion.* / gen_ai.usage.*)。它和装饰器是互补的两条手动埋点入口:装饰器管流程结构, track_llm_call单次模型调用的语义细节。属性含义详见 第 3 章


8. 巧妙之处(可带走的技术)

  • 门面-实现收敛。 五个装饰器、四个废弃异步版,最终只有 entity_method + entity_class 两个实现体;新增一种业务语义只需加一个"设 span kind 后透传"的薄门面 (decorators/__init__.py:107 agent 就是范例)。
  • 装饰时一次判型,运行时零开销分流。 _is_async_method + isasyncgenfunction装饰阶段 就选定包装器(base.py:211),避免每次调用都重新判断同步/异步。
  • 埋点绝不拖垮业务。 未初始化→透明退化(base.py:260);序列化失败→except TypeError: pass (base.py:181);异常→记录后原样 raise(base.py:267)。三道"无害化"设计贯穿始终。
  • 传播靠"只 attach 不 detach"。 set_workflow_name 等把值 attach 进 context 后不留 token (tracing/tracing.py:263),让父级信息沿执行链天然下沉给所有后代 span,无需手动透传参数。
  • 隐私与可观测的双旋钮。 全局 TRACELOOP_TRACE_CONTENT 一键关内容,再用 ContentAllowList 按 association 属性定点放行(tracing/tracing.py:196),既默认保守又可定向排障。

9. 边界与局限(诚实)

  • 生成器不记录输出。 同步/异步生成器路径都没有 _handle_span_output(base.py:214/274), 流式返回值不会出现在 traceloop.entity.output 里。
  • 截断可能产出非法 JSON。 _truncate_json_if_needed 硬切字符串(base.py:44), 下游若想解析 entity.input/output 需容忍这一点。
  • 同步生成器不 detach context。 受上游 OTel 限制刻意不 detach(base.py:104 注释), 与异步生成器行为不对称,极端场景可能残留 context 值(靠 GC 回收)。
  • workflow/agent 名字不在装饰的那个 span 上直接写。 _setup_span 只把它们放进 context, 实际落属性依赖 span processor 的 on_start(tracing/tracing.py:369);若换了非 Traceloop 的 processor 且未接这段逻辑,这些属性可能缺失。
  • entity_class 只包一个方法。 它按 method_name 替换类上的单个方法(base.py:294), 不是给整个类的所有方法埋点。

10. 与本组其它章的关系


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

主题文件路径符号名
公开装饰器 workflow/taskpackages/traceloop-sdk/traceloop/sdk/decorators/__init__.pyworkflow, task
agent/tool 复用 workflow/taskpackages/traceloop-sdk/traceloop/sdk/decorators/__init__.pyagent, tool
会话 ID 装饰器(不建 span)packages/traceloop-sdk/traceloop/sdk/decorators/__init__.pyconversation
废弃异步版packages/traceloop-sdk/traceloop/sdk/decorators/__init__.pyatask, aworkflow, aagent, atool
核心实现:四条包装路径packages/traceloop-sdk/traceloop/sdk/decorators/base.pyentity_method
类方法装饰packages/traceloop-sdk/traceloop/sdk/decorators/base.pyentity_class
开 span + 写语义属性packages/traceloop-sdk/traceloop/sdk/decorators/base.py_setup_span
入参/出参记录packages/traceloop-sdk/traceloop/sdk/decorators/base.py_handle_span_input, _handle_span_output
内容开关packages/traceloop-sdk/traceloop/sdk/decorators/base.py_should_send_prompts
超长截断packages/traceloop-sdk/traceloop/sdk/decorators/base.py_truncate_json_if_needed
生成器 span 生命周期packages/traceloop-sdk/traceloop/sdk/decorators/base.py_handle_generator, _ahandle_generator
上下文写入(传播源头)packages/traceloop-sdk/traceloop/sdk/tracing/tracing.pyset_workflow_name, set_agent_name, set_entity_path
父子路径拼接packages/traceloop-sdk/traceloop/sdk/tracing/tracing.pyget_chained_entity_path
每个 span 起始落属性packages/traceloop-sdk/traceloop/sdk/tracing/tracing.pydefault_span_processor_on_start
association 属性packages/traceloop-sdk/traceloop/sdk/tracing/tracing.pyset_association_properties
白名单与内容放行packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py_span_processor_on_start
内容白名单单例packages/traceloop-sdk/traceloop/sdk/tracing/content_allow_list.pyContentAllowList
手动 LLM spanpackages/traceloop-sdk/traceloop/sdk/tracing/manual.pytrack_llm_call, LLMSpan
association 门面packages/traceloop-sdk/traceloop/sdk/associations/associations.pyAssociations, AssociationProperty
span kind 枚举packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.pyTraceloopSpanKindValues