跳到主要内容

span 加工流水线与可靠导出

30 秒导读: 一个 span 在你的代码里 end() 之后,并不会原样发出去。Logfire 先在内存里把它逐个"重写干净"——改个好名字、按 HTTP 状态码定错误级别、把 SQL 压成一句摘要、把各家 LLM 库的怪 span 富化成统一格式、最后脱敏;然后再用分批 + 落盘重试 + 按体积拆包三道机制,把它可靠地送到后端。本章讲透"为什么 Logfire 的 span 到后端时又干净又可靠"。

本章是数据平面下半程:上半程讲"一个 span 怎么诞生"(01),这里讲"span 结束后到发出网络请求之间"发生了什么。流水线怎么搭起来(装配顺序)是03的事,本章只讲每一环干什么、为什么这么设计


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

先说一个直觉。你在 OpenTelemetry(OTEL,一套厂商中立的可观测性标准)里写下:

with logfire.span('greet {name}', name='world'):
...

span 结束的一刻,OTEL 会把这个"只读快照"(ReadableSpan)交给一串处理器(processor),最后交给导出器(exporter)发到网络。中间这一段——从 span 结束、到 HTTP 请求真正发出——就是本章的主题。

Logfire 在这一段做了两类事:

类别一句话目的
加工(processing)在内存里重写 span 的名字、级别、属性、事件让 span 到后端时干净、好查、好看
可靠导出(export)分批、失败落盘重试、按体积拆包、就地脱敏让 span 到后端时不丢、不炸、不慢

一句话类比: 把它想成快递的打包与发货中心。包裹(span)进来后,先贴统一标签、去掉隐私信息、按易碎/加急分级(加工),再装箱、称重、超重就拆成两箱、发失败就先放仓库待会儿重发(可靠导出)。你的业务代码只管"把包裹丢进来",剩下的中心全包了。


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

2.1 一张流水线图

Logfire 的处理器是一层套一层的洋葱:每个 Wrapper* 都持有内层的一个 processor/exporter,做完自己的事再调用内层。span 从最外层进入,一路穿到最里层,最后由 OTLP 导出器发到网络。

怎么读这张图: 从上到下是一个已结束 span 的旅程;左侧是处理器(改 span、控制批次),右侧是导出器(负责发网络),命中"发送"后失败会拐进右下角的重试仓库。

一个 span 在你的代码里 end()


┌──────────────────────────────────────────────┐
│ ① 抑制闸: 若当前在“别自我观测”上下文里,直接放行不处理 │ CheckSuppressInstrumentationProcessorWrapper
└──────────────────────────────────────────────┘


┌──────────────────────────────────────────────┐
│ ② 重写车间: 把 span 逐个改干净 │ MainSpanProcessorWrapper.on_end
│ 改名/定级/SQL 摘要/LLM 富化/… → 脱敏 │ (一串 _tweak_* / _transform_* + scrubber)
└──────────────────────────────────────────────┘


┌──────────────────────────────────────────────┐
│ ③ 动态分批: 攒够一批或到点了再发(开头故意发得快) │ DynamicBatchSpanProcessor
└──────────────────────────────────────────────┘
│ (一批 span)

┌──────────────────────────────────────────────┐
│ ④ 去重: 同批里若 pending 和最终 span 都在,扔掉 pending │ RemovePendingSpansExporter
└──────────────────────────────────────────────┘


┌──────────────────────────────────────────────┐
│ ⑤ 太大就拆: 请求体超 5MB → 把这批砍两半分别重发 │ RetryFewerSpansSpanExporter
└──────────────────────────────────────────────┘


┌──────────────────────────────────────────────┐
│ ⑥ 静音: 网络异常时吞掉,别让 OTEL 打一坨 traceback │ QuietSpanExporter
└──────────────────────────────────────────────┘


┌──────────────────────────────────────────────┐
│ ⑦ 发货: gzip 压缩后 POST /v1/traces │ BodySizeCheckingOTLPSpanExporter
└──────────────────────────────────────────────┘

├─ 成功 → 完成
└─ 失败 → ┌────────────────────────────────┐
│ 落盘重试仓库: 请求体写磁盘,后台线程 │ OTLPExporterHttpSession
│ 指数退避 + 抖动,慢慢重发 │ → DiskRetryer
└────────────────────────────────┘

注:图里左侧 ①②③ 是处理器(SpanProcessor,on_start/on_end),右侧 ④⑤⑥⑦ 是导出器(SpanExporter,export)。③ 的 DynamicBatchSpanProcessor 内部裹着一个 OTEL BatchSpanProcessor,是"处理器→导出器"的交界。这套洋葱怎么从外到里拼起来03;本章只讲每层做什么。

2.2 部件一句话职责

部件干什么文件
CheckSuppressInstrumentationProcessorWrapper树根,防止 Logfire 观测自己产生的 span_internal/exporters/processor_wrapper.py:42
MainSpanProcessorWrapper导出前把 span 逐个重写干净 + 脱敏_internal/exporters/processor_wrapper.py:61
PendingSpanProcessorspan 一开始就额外发一条"进行中"影子 span_internal/tracer.py:340
DynamicBatchSpanProcessor攒批发送,开头故意发得快_internal/exporters/dynamic_batch.py:12
RemovePendingSpansExporter丢掉已被最终 span 取代的 pending span_internal/exporters/remove_pending.py:12
RetryFewerSpansSpanExporter请求体太大就二分拆包_internal/exporters/otlp.py:263
BodySizeCheckingOTLPSpanExporter真正的 OTLP HTTP 导出,发前查体积_internal/exporters/otlp.py:41
OTLPExporterHttpSession / DiskRetryer请求失败落盘,后台线程退避重试_internal/exporters/otlp.py:66 / :124
SimpleConsoleSpanExporter本地终端彩色打印(旁路)_internal/exporters/console.py:104

3. 加工车间:导出前把 span 重写干净

3.1 为什么要"重写"

OTEL 各家 instrumentation(对第三方库的自动埋点)产出的 span 又乱又不一致:名字有的是 HTTP、有的是一长串,SQL 语句几百字塞在属性里,LangChain / LiteLLM 各用各的属性名。如果原样发出去,UI 里会难看、难查、还可能泄密

Logfire 的策略是:在导出前的最后一刻,统一在内存里重写一遍。承担这活的是 MainSpanProcessorWrapper.on_end

3.2 重写的总入口

on_end 把只读的 span 转成一个可变字典 span_dict,依次跑一串重写函数,最后再封回一个 ReadableSpan:

# processor_wrapper.py:71 MainSpanProcessorWrapper.on_end(真实源码,节选)
span_dict = span_to_dict(span)
_tweak_asgi_send_receive_spans(span_dict) # ASGI send/receive span 改名
_tweak_sqlalchemy_connect_spans(span_dict) # 把 connect span 降到 debug 级
_tweak_http_spans(span_dict) # HTTP span 名字/消息规整
_set_error_level_and_status(span_dict) # 级别 ↔ 状态码 互相补齐
_tweak_fastapi_span(span_dict) # FastAPI 去重复异常事件
_summarize_db_statement(span_dict) # 长 SQL 压成一句摘要
_transform_langchain_span(span_dict) # LangChain/LangSmith span 富化
_transform_google_genai_span(span_dict) # google-genai span 富化
_transform_litellm_span(span_dict) # LiteLLM span 富化
_default_gen_ai_response_model(span_dict) # 补 gen_ai.response.model
self.scrubber.scrub_span(span_dict) # 最后一步:脱敏
span = ReadableSpan(**span_dict)

关键设计:整段包在 with handle_internal_errors:(processor_wrapper.py:72)。任何一个重写函数抛异常,都不会连累 span 本身——最多这次重写没生效,span 照发。可观测性工具绝不能因为自己的加工逻辑而弄丢用户数据

下面挑几个有代表性的重写,讲清它们各自解决的小问题。

3.3 级别与状态码互相补齐

小问题: 用户有时按"日志级别"查错误(level >= error),有时按 OTEL"状态码"查(otel_status_code = ERROR)。这俩本该等价,但很多 span 只有其中一个。

思路: 结束时互相补齐,让两种查法都能查到同一批错误。

_set_error_level_and_status(processor_wrapper.py:89)的规则:

  • 状态是 ERROR 但没有级别 → 补一个 error 级别。
  • 状态未设(unset)但级别 ≥ error → 反过来把状态设成 ERROR。
  • 两者都没有,但有 HTTP 状态码 → 按 span 类型推断:服务端 5xx 或客户端 4xx 记 error,服务端 4xx 记 warning。

一句话:levelotel_status_code 成为可互换的"找错误"入口

3.4 长 SQL 压成一句摘要

小问题: ORM 生成的 SQL 动辄几百字,直接当 span 名/消息会把 UI 撑爆,列表里全是噪音。

思路:INSERT INTO big_table (...) VALUES (...) 这样的长语句,压成 INSERT INTO big_table

入口在 _summarize_db_statement(processor_wrapper.py:291),真正的正则活在 db_statement_summary.pysummarize_query。它对 SELECT / INSERT / CTE / 子查询各写了一条正则(db_statement_summary.py:SELECT_RE 等),把"选了哪些列/插了哪些值"折叠掉,只留操作和表名。原始全文仍在 db.statement 属性里,想看细节点进去看——列表清爽,细节不丢

3.5 把各家 LLM span 富化成统一格式

这是加工车间里最"重"的一组。LangChain、google-genai、LiteLLM 三家产出的 span 属性名、结构各不相同,Logfire UI 想统一展示"对话面板"和"成本",就得把它们翻译成统一的 OTEL 语义约定(semconv)属性和 all_messages_events

函数处理谁主要动作
_transform_langchain_spanopeninference...langchain / langsmith把 JSON 字符串属性登记进 json_schema、拼出 gen_ai.*、组装 all_messages_events
_transform_google_genai_span...instrumentation.google_genaigen_ai.* 事件收进 events 属性、标 gen_ai.operation.name='chat'
_transform_litellm_spanopeninference...litellminput.value/output.value 抽出请求/响应、token 用量、猜测厂商

三者共同的暗线:先靠 instrumentation_scope.name 认出"这是哪家的 span"(_transform_litellm_spanprocessor_wrapper.py:570),不匹配就原样返回;匹配了才动手。加上 _default_gen_ai_response_model(processor_wrapper.py:535)在缺 gen_ai.response.model 时用 gen_ai.request.model 兜底——这些细节共同保证 UI 里不同框架的 LLM 调用长得一样、成本算得对。生态集成的全貌见05

3.6 最后一步:脱敏

重写的最后才跑 self.scrubber.scrub_span(span_dict)。放在最后是有讲究的:前面所有重写可能新造出含敏感值的属性(比如 URL query、SQL、消息),所以脱敏必须兜在最底,扫过成品。脱敏怎么工作(正则、白名单、回调)是06的主题,本章只需记住它是加工车间的出厂质检


4. 代理与 pending span:让 UI 立刻看到"进行中"

4.1 ProxyTracer:为什么要包一层

小问题: OTEL 的 TracerProvider(创建 span 的工厂)一旦拿到就固定了;但 Logfire 允许你import logfire 拿 tracer、之后才 configure(),配置时 provider 会被换掉。

思路: 中间垫一个代理ProxyTracerProvider(tracer.py:55)持有内层真 provider,configure 时用 set_provider(tracer.py:66)把内层替换掉,并回调所有已发出的 _ProxyTracer(tracer.py:259)更新它们的内层 tracer。这样"配置前拿到的 tracer"在配置后自动生效——门面稳定,底座可换。装配细节见03

4.2 start_span:一个 span 起点上的 Logfire 私货

_ProxyTracer.start_span(tracer.py:276)在把创建委托给内层前,先塞两样 Logfire 专属属性:

# tracer.py:297(真实源码,节选)
if self.is_span_tracer:
attributes[ATTRIBUTES_SPAN_TYPE_KEY] = 'span' # 标记这是“真 span”
attributes.setdefault(ATTRIBUTES_MESSAGE_KEY, name) # 消息默认等于名字

创建出的 span 再被裹进 _LogfireWrappedSpan(tracer.py:139)——它登记进 OPEN_SPANS 全局表(用于进程退出时兜底关闭未结束的 span)、接管异常记录、并用可自定义的时间戳生成器覆写 end()。这层"包裹"承接的是01讲的 span 生命周期。

4.3 pending span:进行中 span 的"影子"

小问题: 一个 span 只有结束时才发到后端。可一个耗时 30 秒的 span,你希望 UI 一开始就能看到它正在跑,而不是干等 30 秒。

思路: span 一开始(on_start)就额外发一条"影子 span"——叫 pending span,专门告诉后端"有个 span 开跑了"。真 span 结束后再发一条正式的,后端用它替换掉影子。

干这活的是 PendingSpanProcessor(tracer.py:340)。它在 on_start 里克隆当前 span,改两个关键属性,然后立刻通过内层 processor 的 on_end 发出去:

# tracer.py:380(真实源码,节选)
attributes = {
**attributes,
ATTRIBUTES_SPAN_TYPE_KEY: 'pending_span', # 标记:这是影子,不是真 span
# 记下“真 span 的真实父级”,因为影子自己的 parent 指向的是真 span
ATTRIBUTES_PENDING_SPAN_REAL_PARENT_KEY: trace_api.format_span_id(
span.parent.span_id if span.parent else 0
),
}
...
self.processor.on_end(pending_span) # 借 on_end 把影子当成一条“已结束 span”发出去

几个精妙点:

  • span 类型有三种——SpanTypeType = Literal['log', 'pending_span', 'span'](constants.py:104),靠 ATTRIBUTES_SPAN_TYPE_KEY 区分(constants.py:106)。约定:属性缺失时按"真 span"解释。
  • 父级要单独记。影子 span 的 parent 字段被设成了真 span 自己(这样它在 UI 里挂在真 span 下),于是真 span 的真实父级丢了,得用 ATTRIBUTES_PENDING_SPAN_REAL_PARENT_KEY(constants.py:109)另存。span_id 用 16 进制字符串存,因为 protobuf 编不了 > 2^64 的整数。
  • 它故意不是 WrapperSpanProcessor(tracer.py:341 的类注释说明)。因为它内层的 processor 已经在流水线别处被正常 on_end/shutdown 了,再走一遍 wrapper 的默认实现会重复调用。
  • 影子也要过加工车间:装配时,PendingSpanProcessor 内层裹的正是一个 MainSpanProcessorWrapper(见 config.py:1377),所以 pending span 一样被改名、脱敏后才发出——影子和真身长得一致

这条"影子"会在导出末端被清理,见下节 5.3。


5. 导出与可靠性:又快又不丢

加工完的 span,交给一串导出器发到后端。这一节讲三件事:怎么攒批发得快怎么去掉多余的影子发失败了怎么办

5.1 动态分批:开头快,之后省

小问题: 攒批发送省网络开销,但如果一律"攒 500ms 再发",你刚跑起来盯着终端等第一条数据时,会觉得"怎么半天没反应"。

思路: 开头发得勤,之后转常规DynamicBatchSpanProcessor(dynamic_batch.py:12)初始把批次延迟设成 100ms(响应快),处理满 10 条后再切到常规值(默认 500ms,可用 OTEL_BSP_SCHEDULE_DELAY 环境变量调):

# dynamic_batch.py:29(真实源码)
def on_end(self, span: ReadableSpan) -> None:
self.num_processed += 1
if self.num_processed == 10:
self.schedule_delay_millis = self.final_delay
super().on_end(span)

一句话:头 10 条几乎实时,让你第一眼就看到数据;之后转入省电模式。它内部裹的是 OTEL 标准 BatchSpanProcessor,只是动态改了它的 _schedule_delay(见 schedule_delay_millis setter,dynamic_batch.py:47)。

5.2 洋葱基类:Wrapper 模式

上面这些 Wrapper* 都继承自 wrapper.py 里的小基类。WrapperSpanExporter(wrapper.py:18)/ WrapperSpanProcessor(wrapper.py:56)默认就是"转发给内层",子类只覆写自己关心的那个方法。这让每个可靠性关切(去重、拆包、静音)都能写成一个独立、可组合的小层,叠成前面那张洋葱图。

5.3 去掉多余的影子

小问题: 5.1 攒批时,如果一个短命 span 的 pending 影子和它的最终 span恰好落在同一批里,那影子就纯属多余——最终 span 已经到了,后端不需要再收到"它开始了"。

思路: 发送前扫一遍这批,同批内有最终 span 的,就把对应影子丢掉RemovePendingSpansExporter(remove_pending.py:12)用一个 (trace_id, span_id) → span 的字典去重:

# remove_pending.py:23(真实源码,节选)
for span in spans:
span_type = (span.attributes or {}).get(ATTRIBUTES_SPAN_TYPE_KEY)
if span_type == 'pending_span':
key = (span.parent.trace_id, span.parent.span_id) # 影子的 parent = 真 span
spans_by_id.setdefault(key, span) # 只在还没见过时放,最终 span 会覆盖它
elif span_type == 'span':
key = (span.context.trace_id, span.context.span_id)
spans_by_id[key] = span # 最终 span 直接占位/覆盖影子
else:
result.append(span) # 日志等原样保留

注意两个键取法不同:影子用它的 parent(指向真 span),最终 span 用它自己的 context——两者算出同一个 key,于是最终 span 覆盖影子。而且代码不假设影子一定先出现(setdefault vs 直接赋值),因为攒批后的顺序未必稳定。

5.4 太大就二分拆包

小问题: 后端对单次请求体有大小上限。一批 span 里如果有个别巨型 span(比如塞了大 payload),整批可能超限被拒。

思路: 发前先估体积,真超了就把这批砍成两半分别发,递归下去直到每个请求都够小。这是两个类配合:

  • BodySizeCheckingOTLPSpanExporter(otlp.py:41)在真正 _export 前查序列化后的字节数,超过 max_body_size(5MB,故意远小于后端上限,因为小请求更快更稳)且这批不止一个 span时,抛出 BodyTooLargeError(otlp.py:284)。
  • RetryFewerSpansSpanExporter(otlp.py:263)在外层接住这个异常,把 span 列表二分,对两半分别递归 export:
# otlp.py:269(真实源码,节选)
try:
return super().export(spans)
except BodyTooLargeError:
half = len(spans) // 2
assert half > 0 # 单个 span 不会抛 BodyTooLargeError,所以这里必 > 0
res1 = self.export(spans[:half])
res2 = self.export(spans[half:])
...

为什么单个 span 不拆: _export 里的判断是 self._current_num_spans > 1(otlp.py:59)。如果一批就一个 span 还超限,拆也没用,只能硬发——由后端决定收不收。这套设计优雅地把"要发多少"和"能发多大"解耦了。

5.5 失败落盘重试:不丢数据的最后一道防线

小问题: 网络抖动、后端短暂不可用时,一批 span 发失败了。直接丢掉会丢数据;一直堆在内存里重试会撑爆内存

思路: 把失败请求的请求体写到磁盘,交给一个后台守护线程,用指数退避慢慢重发。这样内存不涨、进程退出也不卡。

链路是这样的:OTLPExporterHttpSession(otlp.py:66,一个 requests.Session 子类)的 post 先试一次,失败先原地等 1 秒再试一次(给瞬时抖动一个机会、同时对导出器施加背压),还失败就交给 DiskRetryer(otlp.py:124):

POST 失败

├─ 首次耗时 < 10s → sleep(1) 再试一次
│ └─ 还失败 → 交给 DiskRetryer
└─ 首次耗时 > 10s → 直接交给 DiskRetryer(别再阻塞导出线程,防批次队列堆满)

DiskRetryer 的要点:

机制做法在哪
落盘省内存请求体写进临时目录的一个 uuid 文件,内存只留路径otlp.py:188 path.write_bytes(data)
有上限磁盘上待重试总量超 512MB 就丢弃新的(而非无限堆积)otlp.py:132 MAX_TASK_SIZE
指数退避 + 抖动失败则延迟翻倍至上限 128s,并乘 (1 + random()) 抖动otlp.py:239
成功后加速一旦成功把延迟降到 0.2s,好快速清空积压otlp.py:252
守护线程Thread(daemon=True),不阻塞进程退出otlp.py:200
限流日志问题最多每 60s 报一次,不刷屏otlp.py:211 _should_log
重试时抑制自我观测with logfire.suppress_instrumentation(): 再 postotlp.py:241

抖动那行有个巧思(otlp.py:236 的注释):抖动正比于延迟,这样如果后端宕机一段时间又恢复,大量堆积的重试请求会被摊平在 MAX_DELAY 这段时间里发出,而不是同一瞬间齐刷刷砸过去把刚恢复的后端再打垮。

外面还有 QuietSpanExporter(otlp.py:291)兜底:网络异常时吞掉并返回 FAILURE,免得 OTEL 自己打印一大坨 traceback 刷屏——真正的错误已由 DiskRetryer 限流报告了。

5.6 抑制自我观测:别让工具观测自己

流水线树根是 CheckSuppressInstrumentationProcessorWrapper(processor_wrapper.py:42)。它在 on_start/on_end 时,若发现当前上下文已被标记"抑制观测",直接返回不处理;否则先把自己也标记成抑制再往下走。

这解决一个尴尬的循环:Logfire 导出 span 要发 HTTP 请求,而 HTTP 库本身可能被埋点——于是"导出 span"这个动作又产生新 span,无限套娃。DiskRetryer 重发时也显式 suppress_instrumentation(见 5.5)正是同一个道理。观测工具必须对自己隐身


6. 本地控制台导出(旁路)

除了发往后端,Logfire 还能把 span 彩色打印到终端,方便本地开发时"边跑边看"。这是一条并行的旁路,和网络导出互不影响,由 console.py 实现。

三种排版,难度递增:

导出器排版文件
SimpleConsoleSpanExporter扁平列表,不缩进console.py:104
IndentedConsoleSpanExporter按父级数量缩进console.py:345
ShowParentsConsoleSpanExporter缩进 + 必要时补印父级(暗色),厘清并发嵌套console.py:388

它同样吃 pending span 的语义:控制台只打印 pending_span(span 开始那一刻)和 log,不打印类型为 span 的结束记录(console.py:167console.py:367),因为 pending 记录带着 span 的起点信息,足够在终端呈现"这条正在发生"。要重建嵌套缩进,它用 _pending_span_parent(console.py:486)从 ATTRIBUTES_PENDING_SPAN_REAL_PARENT_KEY 把真实父级解回来——又一次复用了 4.3 那条"影子记住真父级"的设计verbose 模式下还会用 rich 做 Python 语法高亮把参数漂亮地打出来(console.py:282)。


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

  • 加工与导出彻底解耦成洋葱层。 每个关切(改名、定级、去重、拆包、静音、重试)都是一个只覆写一个方法的 Wrapper* 小层(wrapper.py:18/:56),能自由增删重排。想加一道处理,插一层即可。
  • 脱敏放在重写链最末。 前面的重写可能造出新的敏感属性,所以质检必须兜底扫成品(processor_wrapper.py:84)。顺序即正确性
  • pending span:用一条便宜的"影子"换来实时可见性,再在导出末端顺手去重(remove_pending.py),几乎零额外成本。
  • 失败落盘而非堆内存,加 512MB 上限 + 指数退避 + 正比抖动(otlp.py:239),兼顾"不丢数据"和"不撑爆内存/不打垮刚恢复的后端"。
  • 拆包与批次解耦:导出器只管"太大就抛异常",拆分逻辑在外层独立一层(otlp.py:263),单个超大 span 优雅降级为"硬发"。
  • 动态批次延迟:头 10 条近实时,之后转省电(dynamic_batch.py:29)——一个改善"第一印象"的小而美的体验优化。
  • 全程 handle_internal_errors + suppress_instrumentation:加工出错不连累用户数据,观测工具对自己隐身。

8. 边界与局限

  • 加工是尽力而为的。 重写函数强依赖 instrumentation_scope.name 和特定属性名(如 _transform_litellm_spanopeninference.instrumentation.litellm)。上游库改了 scope 名或属性结构,富化就静默失效(外层 handle_internal_errors 保证不崩,但 UI 里那条 LLM span 会退化成"没富化"的样子)。
  • 单个超大 span 无法拆。 5.4 只能二分一批;若单个 span 本身就超后端上限,只能硬发,可能被拒(otlp.py:59> 1 判断)。
  • 磁盘满/只读时重试会退化。 DiskRetryer 写盘失败会回滚计数并放弃该任务(otlp.py:190);Emscripten/Pyodide 环境没有线程,直接不落盘(otlp.py:96)。
  • pending span 的采样要重复判一次。 因为 Logfire 自己的采样目前在 span 开始之后才检查,PendingSpanProcessor 里得再判一遍 should_sample(tracer.py:367,代码里附了 linear issue 链接)。采样全貌见06

9. 横向对比

本章聚焦"结束→发出"这一段,和同组其它章的分工:


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

主题文件符号
抑制自我观测(树根)logfire/_internal/exporters/processor_wrapper.pyCheckSuppressInstrumentationProcessorWrapper
导出前重写总入口logfire/_internal/exporters/processor_wrapper.pyMainSpanProcessorWrapper.on_end
级别 ↔ 状态码补齐logfire/_internal/exporters/processor_wrapper.py_set_error_level_and_status
长 SQL 压摘要logfire/_internal/exporters/processor_wrapper.py_summarize_db_statement
SQL 摘要正则实现logfire/_internal/db_statement_summary.pysummarize_query
LangChain span 富化logfire/_internal/exporters/processor_wrapper.py_transform_langchain_span
google-genai span 富化logfire/_internal/exporters/processor_wrapper.py_transform_google_genai_span
LiteLLM span 富化logfire/_internal/exporters/processor_wrapper.py_transform_litellm_span
补 response.modellogfire/_internal/exporters/processor_wrapper.py_default_gen_ai_response_model
可替换的 tracer 代理logfire/_internal/tracer.pyProxyTracerProvider / _ProxyTracer.start_span
span 包裹层logfire/_internal/tracer.py_LogfireWrappedSpan
发影子 pending spanlogfire/_internal/tracer.pyPendingSpanProcessor
span 三类型 / 影子父级键logfire/_internal/constants.pySpanTypeType / ATTRIBUTES_PENDING_SPAN_REAL_PARENT_KEY
动态批次延迟logfire/_internal/exporters/dynamic_batch.pyDynamicBatchSpanProcessor
去掉多余影子logfire/_internal/exporters/remove_pending.pyRemovePendingSpansExporter
太大二分拆包logfire/_internal/exporters/otlp.pyRetryFewerSpansSpanExporter / BodyTooLargeError
发前查体积的 OTLP 导出logfire/_internal/exporters/otlp.pyBodySizeCheckingOTLPSpanExporter
失败落盘重试logfire/_internal/exporters/otlp.pyOTLPExporterHttpSession / DiskRetryer
静音网络异常logfire/_internal/exporters/otlp.pyQuietSpanExporter
Wrapper 洋葱基类logfire/_internal/exporters/wrapper.pyWrapperSpanExporter / WrapperSpanProcessor
本地控制台导出logfire/_internal/exporters/console.pySimpleConsoleSpanExporter / IndentedConsoleSpanExporter / ShowParentsConsoleSpanExporter