跳到主要内容

f-string 魔法与 Python 对象的富展示

30 秒导读: 你写 logfire.info(f'user {user_id} bought {item!r}'),Logfire 会偷偷把 user_iditem 这两个被插值的表达式连名带值抽出来,变成结构化的 span 属性;还会把 item 这种任意 Python 对象(dataclass / enum / DataFrame / bytes…)描述成一份 JSON schema, 让后端能按原本的类型把它渲染回来。这一切不需要你多写一行代码。本章讲清这两件"魔法"怎么变的。

本章聚焦 Logfire 最独特的 Python-centric 能力,不覆盖 span 的装配主流程(见 01-public-api-and-span.md)与脱敏机制本身(见 06-sampling-and-scrubbing.md,本章只在调用点提一句 scrubber)。


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

先看普通日志的痛点

传统日志把一切拍平成字符串:

logging.info("user %s bought %s", user_id, item) # 出来的只是一行文本

后端拿到的是 "user 42 bought Widget(id=7)" —— 想按 user_id 过滤、想点开 item 看它的字段? 没门,信息在格式化的一瞬间就被"烧"进字符串里丢掉了。

Logfire 想给你的两样东西

Logfire 的目标是:你像写普通 f-string 一样随手记日志,但拿到的是结构化数据。 拆成两件事:

能力白话你写的Logfire 额外给你的
① f-string 魔法自动把插值的变量变成可查询的属性f'user {user_id} bought {item}'属性 user_id=42item=…,外加干净的模板 'user {user_id} bought {item}'
② 富展示把任意 Python 对象的"类型信息"一起带走item = Widget(...)(一个 dataclass)一份 JSON schema:告诉后端"这是个 dataclass,有哪些字段"

一个最小的直观例子

import logfire
logfire.configure(inspect_arguments=True) # 3.11+ 默认就是 True

item = SomeEnum.RED
logfire.info(f'user {user_id} picked {item}')

后端收到的这条 span,除了消息文本外,还带上了:

  • user_id = 42(可以直接用 SQL where user_id = 42 查)
  • item = "RED",并附带 schema {"type":"string","x-python-datatype":"Enum","title":"SomeEnum",...} —— 后端据此知道它不是普通字符串,而是个枚举,可以特殊渲染。
  • 消息模板 'user {user_id} picked {item}' —— 同一句话的成千上万条日志会聚合到同一个模板下。

一句话直觉

把它想成一个会读你源码的 str.format:普通格式化只吐出最终字符串;Logfire 的格式化 额外回头看了一眼你的源码,记下"这个洞里填的是哪个表达式",于是文本和结构两不误。

本节到此不碰底层。下面开始拆"魔法"怎么变的。


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

两件事其实是一条流水线上的两段。用户调 logfire.info(...) 后,主流程(01 章)会调到本章的两个入口:

用户: logfire.info(f'user {user_id} bought {item}')


┌───────────────────────────────────────────────┐
│ ① 消息格式化 + 自动取参 │
│ formatter.logfire_format_with_magic() │
│ ├─ 有源码帧? → 从 AST 反推 f-string │
│ │ _fstring_chunks(): 逐个 {表达式} eval │
│ └─ 否则 → 当普通模板 _vformat_chunks() │
│ 产出: (最终文本, extra_attrs, 干净模板) │
└───────────────────────────────────────────────┘
│ extra_attrs = {'user_id': 42, 'item': <对象>}

┌───────────────────────────────────────────────┐
│ ② 富展示: 给每个属性值配一份 JSON schema │
│ json_schema.attributes_json_schema_properties │
│ └─ create_json_schema(每个值) 递归描述类型 │
│ (值本身的编码由 json_encoder.to_json_value 做) │
└───────────────────────────────────────────────┘


span 属性: 文本 + user_id/item + JSON schema → 导出到后端

怎么读这张图: 从上到下是先后顺序。①负责"抽出名字和值",②负责"给值贴上类型标签"。 两段的胶水在 main.py:①的 extra_attrs 被合并进属性字典,②随即对这些属性跑 schema。

各部件一句话职责:

部件干什么文件
logfire_format_with_magic①的总入口;容错包装_internal/formatter.py:246
ChunksFormatter._fstring_chunks从 AST 反推 f-string、逐洞求值_internal/formatter.py:61
ChunksFormatter._vformat_chunks没有魔法时按 str.format 模板处理_internal/formatter.py:152
compile_formatted_value把一个 {表达式} 编译成可 eval 的代码(带缓存)_internal/formatter.py:279
CallNodeFinderexecuting 在源码里定位这次调用的 AST 节点_internal/ast_utils.py:147
create_json_schema②的核心;把任意对象递归转成 JSON schema_internal/json_schema.py:99
to_json_value把对象的编码成 JSON 可存的形式_internal/json_encoder.py:246

主线走一遍(高层):logfire.info → 拿到调用者的栈帧 → logfire_format_with_magic 反推 f-string 得到 {名字: 值} → 合并进 span 属性 → 对属性跑 attributes_json_schema_properties 生成 schema → 文本、属性、schema 一起交给 OTLP 导出。


3. 核心原理一:f-string 魔法与自动取参

3.1 它要解决的小问题

f-string 是 Python 的语法糖。运行到 f'user {user_id} bought {item}' 时,解释器当场把它算成 一个普通字符串 'user 42 bought Widget(...)'。函数 logfire.info 拿到的只有这个成品字符串—— user_iditem 这些名字和值,在语言层面已经消失了。

所以难点是:Logfire 只拿到一个字符串,怎么知道它当初是由哪些表达式拼出来的?

答案违反直觉但很务实:回头去读源代码。 Logfire 拿到调用发生时的栈帧,顺着栈帧找到源文件、 定位这一行的 AST,从语法树里把 {user_id}{item} 这些"洞"原样读回来。

3.2 思路/直觉:栈帧 → 源码 → AST → 逐洞求值

分四步,依次进行:

第1步 拿栈帧 inspect.currentframe().f_back (谁调用了 logfire.info)
│ main.py:231, 只有 inspect_arguments=True 才拿

第2步 定位调用节点 executing.Source.executing(frame) → 这一行的 ast.Call
│ ast_utils.py:160 (executing 库靠字节码精确匹配是哪次调用)

第3步 取第一个实参 若它是 ast.JoinedStr → 就是 f-string;否则放弃魔法
│ formatter.py:94 / 100

第4步 遍历 f-string 的每一段:
· 常量段 'user ' → 原样进模板
· {表达式} 段 user_id → eval 求值 + 记进 extra_attrs
formatter.py:120-147

为什么用 executing 而不是自己数括号? 一行里可能有多个函数调用(f(g(), h())),光靠行号 分不清是哪一个。executing(第 3 方库)用字节码指令的精确位置来锁定"当前正在执行的那次调用", 这是整个魔法的地基(ast_utils.py:160,注释直接写着 "This is where the magic happens")。

3.3 原理演示(示意,非源码)

把核心想法用几十行演出来,帮你建立直觉:

# 示意,非源码:f-string 魔法的骨架
import ast, executing, inspect

def fake_info(message): # 假装这是 logfire.info
frame = inspect.currentframe().f_back # ① 调用者的栈帧
call_node = executing.Source.executing(frame).node # ② 这一行的 ast.Call
arg = call_node.args[0] # ③ 第一个实参的 AST

if not isinstance(arg, ast.JoinedStr): # 不是 f-string 就不玩魔法
return

attrs, template = {}, ''
for part in arg.values: # ④ 逐段遍历
if isinstance(part, ast.Constant): # 'user ' 这种字面量段
template += part.value
else: # {user_id} 这种插值段
src = ast.unparse(part.value) # 把表达式还原成源码文本 "user_id"
value = eval(src, frame.f_globals, frame.f_locals) # 求值 → 42
attrs[src] = value # 记成属性 {'user_id': 42}
template += '{' + src + '}'
return template, attrs # 'user {user_id}', {'user_id': 42}

重点看第 ④ 步:表达式的源码文本("user_id")同时充当了属性名,而 eval 出来的是属性值。 真实实现比这严谨得多(缓存、format spec、await 检测、脱敏),但主意就是这个。

3.4 真实实现:逐洞拆解

真正的循环在 _fstring_chunks(_internal/formatter.py:120-150)。三个关键动作:

① 常量段 vs 插值段。 AST 把 f-string 表示成 JoinedStr.values,元素要么是 ast.Constant (不带 {} 的字面文本),要么是 ast.FormattedValue(带 {} 的表达式)。代码据此二分处理 (formatter.py:121:128)。

② 从 AST 而非源码文本重建模板。 注意它直接切源码字符串,而是从 AST 一段段拼出 new_template(formatter.py:116, :138)。注释解释了原因:源码里可能有转义引号、相邻字符串隐式 拼接,直接切会乱。这样 f'user {user_id}' 得到的模板永远是干净的 'user {user_id}'

③ 一次表达式,两次 eval。 对每个 {表达式}:

value = eval(value_code, global_vars, local_vars) # formatter.py:141 求"值"
extra_attrs[source] = value # 值进属性字典(结构化)
formatted = eval(formatted_code, global_vars, {..., '@fvalue': value}) # :145 求"文本"

一次求(拿去当结构化属性),一次按 format spec / 转换求文本(拼进消息字符串)。 两次分开,是为了让 {item!r:>10} 里的 !r:>10 只影响显示文本,而结构化属性存的是原始对象

3.5 关键细节:compile_formatted_value 的两个 code object

单看 {表达式} 怎么变成上面那两个 eval 的输入?这是 compile_formatted_value (_internal/formatter.py:279,@lru_cache 缓存,因为同一行会被反复命中)干的:

产物是什么怎么来
source表达式的源码文本,如 "user_id"get_node_source_text(ast_utils.py:249)
value_code编译好的求值代码compile(source, '<fvalue1>', 'eval')(formatter.py:293)
formatted_code编译好的"套上 format spec 再转字符串"的代码构造一个只含 @fvalue 变量的迷你 JoinedStr 再 compile(formatter.py:294-313)

巧妙处在 formatted_code:它重建了一个 f-string AST,但把原来的表达式换成一个变量名 @fvalue (故意带 @,普通变量名不可能撞上),这样第二次 eval 就不用重算表达式,直接复用第一次的 value, 同时保留原本的 conversion(!r)和 format_spec(:>10)。

get_node_source_text 本身也有讲究(ast_utils.py:249-264):优先用 ast.get_source_segment原始源码,但会用 ast.unparse 交叉校验;老版本 Python 在 f-string 内部的 AST 源码定位有 bug, 校验不过就退回 ast.unparse 的结果。这是"宁可退化,不要出错"。

3.6 容错:两种命名异常 + 兜底

魔法很花哨,但绝不能因为它失败就让用户的日志丢了。容错分层做在 logfire_format_with_magic (_internal/formatter.py:257-275):

try 正常格式化
├─ KnownFormattingError → warn_formatting() 提示"你可能传了预格式化字符串"
├─ FStringAwaitError → warn_fstring_await() 提示"f-string 里不能 await,先算好再记"
└─ 其它 Exception → log_internal_error() 当作我们自己的 bug 记内部错
最后一律:return (原始 format_string, {}, 原始 format_string) ← 兜底,日志照样出

两种命名异常各管一类问题:

  • KnownFormattingError(formatter.py:317)——对应 str.format 本会报的错(空 {}、字段未定义、 数字字段名等)。语义是"这多半是用户误传了一个已经格式化好的普通字符串,不是模板"。在 _vformat_chunks 里被大量抛出(如 formatter.py:177:192:195)。
  • FStringAwaitError(formatter.py:325)——在 compile_formatted_value 里,编译前先 ast.walk 扫有没有 ast.Await(formatter.py:289-291)。f'{await get()}' 没法在事后 eval 里重放,于是明确 报错并教用户改写成"先 value = await get() 再记"。

无论哪种失败,最后都退回把原始模板字符串当消息,extra_attrs 为空——魔法没了,但日志还在。

3.7 没有魔法时:_vformat_chunks

如果 inspect_arguments=False,或者传入的本就不是 f-string 而是 str.format 风格模板 (logfire.info('user {user_id}', user_id=42)),就走 _vformat_chunks (_internal/formatter.py:152)。它抄改自 CPython 的 string.Formatter._vformat,在标准解析之上加了两处:

  • 支持 {x=} 调试语法:检测字段名以 = 结尾时,把 x= 原样保留进文本(formatter.py:180-185)。
  • 支持带点的属性名:OTEL 属性名常含 .,若 a.b 当表达式取值失败,就退一步直接 kwargs['a.b'] 整体取(formatter.py:197-204)。

两条路(_fstring_chunks / _vformat_chunks)最终都产出同一种结构:一列 chunks(文本片段)、 一个 extra_attrs、一个模板字符串(ChunksFormatter.chunks 汇合,formatter.py:34)。

3.8 与脱敏、与主流程的接缝(只提一句)

格式化过程中,每个字段的显示文本都会过一遍 MessageValueCleaner.clean_value (scrubbing.py:372,在 formatter.py:146:225 调用),把消息里可能的敏感值涂掉——脱敏本身 见 06,这里只是它的一个调用点。

拿到结果后,主流程 main.py:235-244extra_attrs 合并进 span 属性、把干净模板写进 ATTRIBUTES_MESSAGE_TEMPLATE_KEY——于是同一模板的日志能聚合。span 装配细节见 01


4. 核心原理二:Python 对象的富展示(JSON schema)

4.1 它要解决的小问题

上一步 extra_attrs 里的值可能是任意 Python 对象:一个 dataclass、一个 Enum、一个 bytes、 一个 pandas DataFrame。OTLP(OpenTelemetry 协议)的属性只能存标量和数组——存不下这些富类型。

如果只是 str(item) 拍平,后端就只剩一坨文本,丢了结构。问题是:怎么把"这是什么类型、长什么样" 这份元信息也带到后端,让它能把对象按原本的样子渲染回来?

Logfire 的答案:给每个属性值额外生成一份 JSON schema,用自定义关键字 x-python-datatype 标注它的 Python 出身。值本身正常编码,schema 单独放在一个 span 属性里,后端两者对照即可还原。

4.2 思路:值和"值的类型描述"分开走

关键设计是双轨:

一个 Python 对象 item = Widget(id=7, name='x') (一个 dataclass)

├─► 值编码: json_encoder.to_json_value(item)
│ → {"id": 7, "name": "x"} (能存进 OTLP 的普通 JSON)

└─► 类型描述: json_schema.create_json_schema(item)
→ {"type":"object","title":"Widget",
"x-python-datatype":"dataclass",
"properties":{...}} (告诉后端:这是 dataclass)

后端拿到 {"id":7,"name":"x"} + 那份 schema,就能显示成"一个名为 Widget 的 dataclass,字段 id/name"。 x-python-datatype 这个自定义键(前缀 x- 避免和官方 JSON Schema 关键字冲突)是还原的钥匙, 文件顶部的 docstring 列了全部自定义键(json_schema.py:1-13)。

4.3 create_json_schema:类型分派的漏斗

核心函数 create_json_schema(_internal/json_schema.py:99)是一个按类型逐层下探的漏斗, 顺序是精心排的(快的、常见的在前),命中即返回:

create_json_schema(obj)
1. obj is None → {'type':'null'}
2. str/int/bool/float(精确类型) → {} ← 空 schema:后端本就认得,不用描述
3. id(obj) 已在 seen 里 → {} ← 防循环引用
4. list/tuple/set/frozenset/deque → _array_schema (递归每个元素)
5. Mapping → _mapping_schema (递归每个值)
6. SQLAlchemy 模型 → _sqlalchemy_schema
7. dataclass 实例 → _dataclass_schema
8. attrs 实例 → _attrs_schema
9. 查 type_to_schema 表(按 MRO 逐个基类找) → 命中的 schema 或工厂函数
10. str/int/float 的子类 → {}
11. 其它 Sequence → {'type':'array', 'x-python-datatype':'Sequence'}
12. 兜底 → {'type':'object','x-python-datatype':'unknown'}

几个要点:

  • 第 2 步返回空 {} 是刻意的:基础标量后端本就能渲染,不必浪费 schema。PLAIN_SCHEMAS (json_schema.py:225)定义了这类"无需描述"的 schema,上层会把它们从属性里剔除。
  • 第 3 步的 seen 集合贯穿始终(存 id(obj),json_schema.py:119-123),递归进容器时传下去, 防止自引用对象把生成器打进死循环。
  • 第 9 步按 __mro__(json_schema.py:140-146):沿着继承链逐个基类查表,所以一个 IPv4Address 的子类也能命中 IPv4Address 的规则。

4.4 type_to_schema:一张可扩展的类型表

第 9 步查的表是 type_to_schema()(_internal/json_schema.py:41,@lru_cache 只建一次)。它把 一堆内置类型映射到"静态 schema 字典"或"schema 工厂函数":

类型产出 schema(节选)静态还是工厂
datetime.datetime{'type':'string','format':'date-time','x-python-datatype':'datetime'}静态
Decimal{'type':'string','format':'decimal','x-python-datatype':'Decimal'}静态
bytes{'type':'string','x-python-datatype':'bytes'}工厂 _bytes_schema
Enumenum 取值列表工厂 _enum_schema
range / PosixPath / UUID / IP 各类各自的 format静态

而且这张表是按环境可选扩展的:用 contextlib.suppress(ModuleNotFoundError) 试探性 import pydantic / pydantic_core / numpy / pandas,装了才加进表(json_schema.py:65-92)。所以 pandas.DataFrame_pandas_schemanumpy.ndarray_numpy_schema 只在依赖存在时才注册, 不给没装这些库的用户添负担。

4.5 复合对象:统一走 _custom_object_schema

dataclass / attrs / Pydantic model / SQLAlchemy 这些"有字段的对象",最终都汇到 _custom_object_schema(_internal/json_schema.py:441):

# 真实实现骨架,见 json_schema.py:441
def _custom_object_schema(obj, datatype_name, keys, seen):
properties = {}
for key in keys:
with suppress(Exception): # 取某个字段失败不致命,跳过
properties[key] = getattr(obj, key)
return {'type': 'object', 'title': obj.__class__.__name__,
'x-python-datatype': datatype_name, **_properties(properties, seen)}

差别只在怎么列出字段名:

对象种类字段来源入口
dataclassdataclasses.fields(obj)(仅 repr=True 的)_dataclass_schema json_schema.py:182
Pydantic modelmodel_fields + model_extra_pydantic_model_schema :355
attrsobj.__attrs_attrs___attrs_schema :408
SQLAlchemysa_inspect(obj).attrs(排除未加载的)_sqlalchemy_schema :415

_properties(json_schema.py:429)对每个字段值再递归 create_json_schema,并把结果里的 "平凡 schema"(PLAIN_SCHEMAS)丢弃——只留下真正需要描述的字段,schema 不至于膨胀。

4.6 容器的取巧:homogeneous 检测

数组和字典不会傻乎乎给每个元素都塞一份 schema。以字典 _mapping_schema(json_schema.py:243)为例:

  • 小字典(≤10 键)→ 逐键给 properties
  • 中/大字典 → 先跑 _check_homogeneous(json_schema.py:228):若所有值的 schema 都一样,只存 一份 additionalProperties;否则中等的逐键存,大而杂的干脆退回 {'type':'object'} 不细描。

数组 _array_schema(json_schema.py:270)同理:元素 schema 全同就用一个 items,否则用 prefixItems 列出每个。这是用尽量少的字节表达"这一批长得都一样"

4.7 值的编码:json_encoder.to_json_value

schema 描述"类型",而本身由 _internal/json_encoder.py 编码。核心 to_json_value (json_encoder.py:246)结构和 create_json_schema镜像的:同样先标量、同样 seen 防环 (遇到循环引用返回 '<circular reference>',json_encoder.py:256-257)、同样按 __mro__encoder_by_type()(json_encoder.py:194)表。

编码策略举例:

类型编码成参考
bytesrepr() 去掉 b'…' 外壳_bytes_encoder json_encoder.py:26
datetime 各类.isoformat()_to_isoformat :51
Enum.value 再递归encoder_by_type :206
pandas DataFrame抽样行列后转 list(避免超大)_pandas_data_frame_encoder :63
兜底safe_repr(o)json_encoder.py:304

最外层的 logfire_json_dumps(json_encoder.py:307)用 json.dumps(default=…),并特意 allow_nan=False——因为标准 JSON 没有 NaN,to_json_value 会先把非有限浮点转成字符串 (json_encoder.py:251-253)。

4.8 三个入口函数:schema 怎么挂到 span 上

主流程只碰三个函数(__all__ 导出,json_schema.py:38):

函数作用位置
attributes_json_schema_properties整批属性,逐个 create_json_schema,得到 {属性名: schema}json_schema.py:169
attributes_json_schema把上面的结果包成 {'type':'object','properties':…}dump_json 成字符串json_schema.py:164
create_json_schema单个值 → schema(前面 4.3 讲的漏斗)json_schema.py:99

attributes_json_schema_properties 还会用 EXCLUDE_KEYS(json_schema.py:179)过滤掉栈信息 (STACK_INFO_KEYS)和脱敏标记(ATTRIBUTES_SCRUBBED_KEY)——这些是 Logfire 内部塞的,不该出现在 "用户属性"的 schema 里。

main.py:248-249 收口:生成的 schema 字符串被写进 ATTRIBUTES_JSON_SCHEMA_KEY 这个 span 属性, 和值一起导出。至此,一个富对象的"值 + 类型"就都到了后端手里。


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

  • 用源码当"元数据来源"。 f-string 魔法的本质是:运行时信息不够,就回去读源代码的 ASTexecuting 靠字节码精确定位调用节点(ast_utils.py:160),把"哪个表达式填了哪个洞"这种编译期 就丢掉的信息硬找回来。
  • 表达式源码文本 = 属性名。 extra_attrs[source] = value(formatter.py:142)——{user.id} 直接变成属性名 user.id,零配置的"结构化"。
  • 一算两用,@fvalue 复用求值。 compile_formatted_value 造一个变量名带 @ 的迷你 f-string (formatter.py:304),让"套 format spec"这步不必重算表达式,还杜绝了变量名撞车。
  • 失败必须无害。 三层异常兜底(formatter.py:257-275)保证魔法或 schema 崩了,日志照出—— 可观测性工具绝不能因为自己出错而吞掉用户的信号
  • schema 与 encoder 镜像同构。 类型描述和值编码走两套几乎一样的分派逻辑(漏斗 + seen + MRO 表), 职责清爽:一个管"是什么",一个管"值多少"。
  • 依赖可选、按需注册。 suppress(ModuleNotFoundError) 让 numpy/pandas/pydantic 的支持"装了才有" (json_schema.py:65-92),核心不背重依赖。

6. 边界与局限(诚实)

  • 魔法依赖能拿到源码。 交互式 shell、exec()、只有 .pyc 没有 .py 时,executing 找不到节点, 会 warn_inspect_arguments 并退回普通格式化(ast_utils.py:171-180)。
  • Python < 3.11 默认关闭。 旧版 executing 算法遇到 auto-tracing 改写过的字节码会对不上源码, 所以 inspect_arguments 只在 3.11+ 默认开(ast_utils.py:183-188 的注释说明)。
  • f-string 里不能 await f'{await x()}' 无法事后 eval 重放,直接 FStringAwaitError (formatter.py:289-291),需用户先算好。
  • 求值有副作用风险。 魔法会 eval 你写在 {} 里的表达式(formatter.py:141);若表达式本身有 副作用,它会被再执行一次。
  • 富展示会退化。 非基础类型的 Enum("type" 恒为 object,json_schema.py:205-206 的注释)、 大而杂的字典(退回裸 {'type':'object'},:262)、完全不认识的对象(x-python-datatype: unknown, :156)都只能给出粗略描述。

7. 横向对比

同 shelf 的 evals-observability 兄弟里,可观测性工具大多止步于"把值拍平成字符串或标准 OTLP 属性"。Logfire 的差异点是把语言层的结构也抢救出来:

  • f-string 反推是 Logfire 特有的"Python-centric"取巧——多数 SDK 要求你手写 key=value 才能得到 结构化字段,Logfire 让你白写 f-string 就有。
  • x-python-datatype 富 schema把类型信息独立成一条元数据,而非污染值本身,后端得以按 Python 类型 渲染——这在纯 OTLP 语义下是做不到的。

需要对照 span 如何被装配、导出的读者,回到本组的 01-public-api-and-span.md04-processing-and-export.md;脱敏这道闸见 06-sampling-and-scrubbing.md


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

主题文件路径符号名
① 格式化总入口 + 三层容错logfire/_internal/formatter.py:246logfire_format_with_magic
两条路的汇合logfire/_internal/formatter.py:34ChunksFormatter.chunks
从 AST 反推 f-string、逐洞求值logfire/_internal/formatter.py:61ChunksFormatter._fstring_chunks
无魔法时的 str.format 路径logfire/_internal/formatter.py:152ChunksFormatter._vformat_chunks
一个 {} 编译成两个 code objectlogfire/_internal/formatter.py:279compile_formatted_value
已知格式化错误logfire/_internal/formatter.py:317KnownFormattingError
f-string 内 await 错误logfire/_internal/formatter.py:325FStringAwaitError
定位本次调用的 ast.Calllogfire/_internal/formatter.py:363FormattingCallNodeFinder
executing 定位 + 源码回取logfire/_internal/ast_utils.py:147CallNodeFinder
取节点原始源码(带校验)logfire/_internal/ast_utils.py:249get_node_source_text
脱敏调用点(细节见 06)logfire/_internal/scrubbing.py:372MessageValueCleaner.clean_value
② 单值 → JSON schema 漏斗logfire/_internal/json_schema.py:99create_json_schema
类型 → schema 映射表(可选扩展)logfire/_internal/json_schema.py:41type_to_schema
复合对象统一 schemalogfire/_internal/json_schema.py:441_custom_object_schema
批量属性 → schema 属性logfire/_internal/json_schema.py:169attributes_json_schema_properties
schema 打包成字符串logfire/_internal/json_schema.py:164attributes_json_schema
值编码(与 schema 镜像)logfire/_internal/json_encoder.py:246to_json_value
类型 → 编码器映射表logfire/_internal/json_encoder.py:194encoder_by_type
主流程接缝(合并 attrs + 挂 schema)logfire/_internal/main.py:235-249Logfire._span