跳到主要内容

门面与一个 span 的诞生

30 秒导读: 你写 logfire.span("...")logfire.info("..."),顶层那个 logfire.span 其实只是一个绑定方法的别名,真正干活的是一个 Logfire 实例。本章追这条路的上半程: 从顶层函数 → Logfire._span() 把一堆材料(栈位置、格式化后的消息、JSON schema、tags、level) 装配成一批 OpenTelemetry 属性 → 造出一个还没真正开始LogfireSpan 对象。至于这个 span 何时真正落到 tracer、怎么导出,是第 4 章的事;f-string 里 {a=} 那点魔法怎么变出属性,是第 2 章的事。

本章属于 Pydantic Logfire 讲解的一部分。总览见 index.md;同组还有 02 f-string 魔法03 configure 装配流水线04 加工与导出05 自动埋点06 采样与脱敏


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

一句话: 这一层是 Logfire 的门面(facade)——你导入 logfire 后能直接调用的那些顶层函数 (logfire.spanlogfire.infologfire.instrument …),以及它们背后那个真正的类 Logfire

它解决什么问题? OpenTelemetry(简称 OTel,一套厂商中立的可观测性标准)原生的 API 很啰嗦: 要拿 tracer、手动 start_span、手动设一堆属性、手动 end。Logfire 想让你一行就能开一个 span, 还能顺手把变量记进去:

import logfire

logfire.configure()

with logfire.span('查询用户 {user_id}', user_id=42) as span: # 一行开一个 span
logfire.info('命中缓存') # 一行记一条日志
span.set_attribute('rows', 3) # 随手补个属性

直觉类比:logfire 这个模块当成一台前台服务机的总机按钮面板。你按下 span 这个按钮, 面板并不自己处理业务,而是把电话转接给一个真正的接线员——一个 Logfire 类的实例。所有 按钮(span/info/instrument/…)背后接的都是同一个默认接线员。本章讲的就是:按钮怎么接到 接线员(§2),以及接线员接到 span 请求后,如何准备好一个待处理的通话记录(§3~§5)。

本节到此不碰代码细节。记住一件事:顶层函数 ≈ 别名,真正的逻辑都在 Logfire 类里。


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

2.1 门面是怎么"变"出来的

打开 logfire/__init__.py,你会看到一个模块级的实例,和一长串把它的方法抠出来当顶层名字的赋值:

# 示意,非源码;真源码见 logfire/__init__.py:34-36
DEFAULT_LOGFIRE_INSTANCE = Logfire() # 造一个默认接线员
span = DEFAULT_LOGFIRE_INSTANCE.span # 顶层 logfire.span 就是它的 .span 方法
instrument = DEFAULT_LOGFIRE_INSTANCE.instrument
info = DEFAULT_LOGFIRE_INSTANCE.info
# ... 几十个,一路到 metrics / variables

真源码里,DEFAULT_LOGFIRE_INSTANCE: Logfire = Logfire()logfire/__init__.py:34,紧跟着 span = DEFAULT_LOGFIRE_INSTANCE.span(:35)、instrument = ...(:36),日志方法族 log/trace/debug/info/notice/warn/warning/error/fatal/exception:82-91 集中绑定。

关键点: logfire.span 是一个绑定方法(bound method),self 已经固定成那个默认实例。 所以"顶层函数"和"实例方法"是同一段代码,只是入口不同。

2.2 谁是接线员:Logfire

Logfire 定义在 logfire/_internal/main.py:151。它构造时只存了几样轻量配置——本章会反复用到:

字段干什么源码
_config指向全局 LogfireConfig(tracer、scrubber、min_level…都从这拿)main.py:164
_tags这个实例默认带的 tags(with_tags 会造带 tag 的新实例)main.py:163
_sample_rate实例级采样率(None 表示不覆盖)main.py:165
_console_log是否往控制台打main.py:166
_otel_scopeOTel 的 instrumentation scope 名,默认 'logfire'main.py:167

注意:构造函数里没有碰 tracer。tracer 是懒加载的 cached_property——_spans_tracer (main.py:196)、_logs_tracer(main.py:192)第一次被访问时才通过 _config 拿到。这一点很重要: 它意味着 Logfire()configure() 之前就能安全地存在(见第 3 章)。

2.3 一次 logfire.span(...) 的上半程流向

下面这张图是本章主线。怎么读:从上往下是调用顺序;左边是"入口层",右边注明发生了什么。 虚线框 LogfireSpan(...) 是本章的终点——一个已装配好、但还没 start 的对象。

用户代码: with logfire.span('查询 {uid}', uid=42) as s: ...


[门面] logfire.span == DEFAULT_LOGFIRE_INSTANCE.span __init__.py:35


[入口] Logfire.span(...) main.py:562
· 校验:属性名不能以 '_' 开头
· 原样转发给 self._span(...)


[装配] Logfire._span(...) ★本章核心★ main.py:207
1. level 过滤:太低 → 直接返回 NoopSpan main.py:219-223
2. 采集调用点:get_user_stack_info() main.py:227
3. f-string 魔法 + 格式化消息(→ 第2章) main.py:235-244
4. prepare_otlp_attributes:转成 OTel 能收的类型 main.py:246
5. 附 JSON schema / tags / sample_rate / level main.py:248-264
6. new LogfireSpan(...) ← 还没 start! main.py:266-273


┌───────────────────────────┐
│ LogfireSpan(待处理) │ main.py:3113
│ _span 仍是 None │
└───────────────────────────┘

with 进入时才真正开始 → __enter__ → _start()/_attach()
(§6 讲生命周期,tracer 内部 → 第4章)

一句话:_span() 是"把材料备齐、把桌子摆好"的地方;真正"开火"要等 with 进入。


3. 核心机制一:门面转发与"属性名不能下划线开头"

3.1 它要解决的小问题

顶层入口(span/info/trace/…)几乎只做两件事:挡一道校验,然后转发。校验的是: 用户传进来的关键字参数(会变成 span 属性)不能以 _ 开头——因为 _tags_level_span_name 这些带下划线的名字被 Logfire 自己占用了(见 span 的签名)。

3.2 真实实现

Logfire.span(main.py:562-608)签名里,给用户的"数据"走 **attributes,给框架的"控制项" 走带下划线的具名参数:

# 真源码节选 main.py:598-608,符号 Logfire.span
if any(k.startswith('_') for k in attributes):
raise ValueError('Attribute keys cannot start with an underscore.')
return self._span(
msg_template, attributes,
_tags=_tags, _span_name=_span_name, _level=_level,
_links=_links, _span_kind=_span_kind,
)

这道 startswith('_') 校验在每个入口都重复出现:trace(main.py:338)、debug(:370)、 info(:402)、notice(:434)、warning(:468)、error(:502)、fatal(:534) 全都有。 它保证了"控制项命名空间"和"用户数据命名空间"永不打架。

日志方法族的共性: trace/debug/info/notice/warning/error/fatal(main.py:310-536) 彼此几乎是复制粘贴——校验完就调 self.log('<级别>', msg_template, attributes, tags=_tags, exc_info=_exc_info)。 两个特例值得记:

  • warn = warning(main.py:472)—— warn 只是 warning 的别名,同一个函数对象。
  • exception(main.py:538)—— 等价于 error,但 _exc_info 默认 True,即默认带上当前异常栈。

4. 核心机制二:_span() 如何把一批材料装配成 OTLP 属性

这是全章的心脏。Logfire._span(main.py:207-276)不创建真正的 OTel span,它准备属性。 按代码顺序拆成六步。

4.1 第一步:level 过滤——够不够格记录?

如果调用者给了 _level,先把它翻成一个数字级别;低于配置的 min_level 就当场短路, 返回一个什么都不做的 NoopSpan,连属性都懒得装:

# 真源码 main.py:219-223,符号 Logfire._span
if _level is not None:
level_attributes = log_level_attributes(_level)
level_num = level_attributes[ATTRIBUTES_LOG_LEVEL_NUM_KEY]
if level_num < self.config.min_level:
return NoopSpan()
  • log_level_attributes(constants.py:92)把 'info' 这类名字查表转成数字(找不到就 warn 并按 error 记)。
  • min_level 来自配置,默认 0(全放行),见 config.py:858-862
  • 返回 NoopSpan 而不是 None刻意的——这样 with logfire.span(...) as s: s.set_attribute(...) 即便被过滤掉也不会炸。NoopSpan 见 §6.3。

4.2 第二步:采集调用点 get_user_stack_info()

Logfire 要在记录里标出"这行日志是你代码的哪一行发的",而不是 Logfire 内部某一行:

# 真源码 main.py:227-228,符号 Logfire._span
stack_info = get_user_stack_info()
merged_attributes = {**stack_info, **attributes}

get_user_stack_info(stack_info.py:78)沿调用栈往外走,跳过非用户代码的帧,找到第一个 "用户帧",取出 code.filepath / code.lineno / code.function 三个属性。什么算"非用户代码"? is_user_code(stack_info.py:112)的规则:标准库、site-packages(OTel 装在哪算哪)、logfire 包 自身、以及 <...> 这类动态生成的文件,都不算——于是栈信息精准落在你写 logfire.span 的那一行

注意合并顺序 {**stack_info, **attributes}:用户属性在后,万一同名会覆盖栈信息

4.3 第三步:f-string 魔法 + 格式化消息

这一步把模板 '查询 {uid}' 和参数变成一条人读的消息,同时可能从 f-string 里"变"出额外属性。 细节属于第 2 章,这里只看接缝:

# 真源码 main.py:230-244,符号 Logfire._span(节选)
if self._config.inspect_arguments:
fstring_frame = inspect.currentframe().f_back # 抓调用者的帧,给 f-string 魔法用
else:
fstring_frame = None

log_message, extra_attrs, msg_template = logfire_format_with_magic(
msg_template, merged_attributes, self._config.scrubber, fstring_frame=fstring_frame,
)
merged_attributes.update(extra_attrs)
attributes.update(extra_attrs) # 也塞回 attributes,给 JSON schema 用
merged_attributes[ATTRIBUTES_MESSAGE_TEMPLATE_KEY] = msg_template # 原始模板
merged_attributes[ATTRIBUTES_MESSAGE_KEY] = log_message # 渲染后的消息

要记住三点:

  1. inspect_arguments 是开关——关掉就不抓帧,{uid=} 那类魔法就退化成普通 .format
  2. logfire_format_with_magic 顺手做脱敏(传入 self._config.scrubber,见第 6 章)。
  3. 装配出两个关键字段:消息模板(ATTRIBUTES_MESSAGE_TEMPLATE_KEY = 'logfire.msg_template', constants.py:115)和渲染后消息(ATTRIBUTES_MESSAGE_KEY = 'logfire.msg',constants.py:118)。 Logfire 后台正是靠"模板"把同一类日志聚成一组,又靠"消息"给人看具体那一条。

4.4 第四步:prepare_otlp_attributes——把 Python 值降格成 OTel 能收的类型

OpenTelemetry 的属性值只允许有限几种类型(str/bool/int/float 及其同类序列)。用户可能塞进来一个 dict、一个 dataclass、一个 Enum——这些得先转换:

# 真源码 main.py:246,符号 Logfire._span
otlp_attributes = prepare_otlp_attributes(merged_attributes)

prepare_otlp_attributes(main.py:3340)对每个值调 prepare_otlp_attribute(main.py:3348),规则:

值类型怎么处理
EnumJSON 序列化(logfire_json_dumps)
int 且 > 2**63-1超出 OTLP 64 位上限,warn 后转成字符串(OTLP_MAX_INT_SIZE,constants.py:165)
int(正常)原样保留
float(非有限,如 inf/nan)转成字符串
float(正常)/str/bool原样保留
其它(dict/对象/…)JSON 序列化

要点: 到这一步,属性值已经全是 OTel 合法类型。原始的富类型信息不会丢——它被记录进了 JSON schema(下一步),这样后台还能把字符串还原回结构化展示。

4.5 第五步:附加 JSON schema / tags / sample_rate / level

装配的收尾,四样东西按需塞进 otlp_attributes:

# 真源码 main.py:248-264,符号 Logfire._span(节选)
if json_schema_properties := attributes_json_schema_properties(attributes):
otlp_attributes[ATTRIBUTES_JSON_SCHEMA_KEY] = attributes_json_schema(json_schema_properties)

tags = (self._tags or ()) + tuple(_tags or ())
if tags:
otlp_attributes[ATTRIBUTES_TAGS_KEY] = uniquify_sequence(tags)

sample_rate = (
self._sample_rate
if self._sample_rate is not None
else otlp_attributes.pop(ATTRIBUTES_SAMPLE_RATE_KEY, None)
)
if sample_rate is not None and sample_rate != 1:
otlp_attributes[ATTRIBUTES_SAMPLE_RATE_KEY] = sample_rate

if level_attributes is not None:
otlp_attributes.update(level_attributes)

逐条看:

  • JSON schema:从原始 attributes(不是降格后的)算出结构描述,存进 logfire.json_schema, 让后台能富展示。计算细节在第 2 章
  • tags:实例默认 tag(self._tags)+ 本次 _tags 合并,uniquify_sequence 去重且保序
  • sample_rate:实例级优先;否则从属性里 pop 出来(可能来自 with_settings)。恒等于 1 就不写 ——1 表示"全采样",写了也没意义。采样机制见第 6 章
  • level:把 §4.1 算好的 level 属性合并进去。

4.6 第六步:造出——但不启动——LogfireSpan

# 真源码 main.py:266-273,符号 Logfire._span
return LogfireSpan(
_span_name or msg_template, # 没给显式名字就用模板当 span 名
otlp_attributes,
self._spans_tracer, # 懒加载的 tracer 引用,此刻只是"交给它"
json_schema_properties,
links=_links,
span_kind=_span_kind,
)

这就是本章的终点。 返回的 LogfireSpan 内部 _span 还是 None——OTel 层面什么都还没发生。 只有当用户 with 进入(或手动 _start())时才真正开始(§6)。

最后,整个 _span 包在 try/except 里:任何内部异常都会 log_internal_error()降级返回 NoopSpan (main.py:274-276)。Logfire 的一条铁律:观测工具自己出 bug,绝不能拖垮用户的业务代码。


5. 两条"快车道":log()_fast_spaninstrument

_span() 是通用装配路。为不同场景,Logfire 另有几条并行路径,值得对照理解。

5.1 log():日志不是 span,是"零时长的 span"

logfire.info(...) 一族最终都汇入 Logfire.log(main.py:718)。它和 _span 装配步骤高度相似 (栈信息、f-string、prepare_otlp_attributes、JSON schema、tags、sample_rate 一样不少),但结局不同: 它立刻 start 又立刻 end,做成一个瞬时事件。

# 真源码 main.py:809-835,符号 Logfire.log(节选)
start_time = self._config.advanced.ns_timestamp_generator()
span = self._logs_tracer.start_span(msg_template, attributes=otlp_attributes, start_time=start_time)
if not span.is_recording():
return
if exc_info: # 需要就把异常记成 span event,error 级别还设 status
...
span.end(start_time) # start 和 end 用同一个时间戳 → 零时长

三个和 _span 的差异:

  1. 走的是 _logs_tracer(main.py:192)而不是 _spans_tracer——日志用独立 tracer。
  2. 属性里显式打上 ATTRIBUTES_SPAN_TYPE_KEY: 'log'(main.py:786),后台据此区分日志与真 span。
  3. level 过滤在开头就做(main.py:751-752),和 _span 同理。

还有个巧妙细节:如果属性里已经有 logfire.msg(说明某个日志集成已经填好了消息),就跳过 f-string 格式化,直接用现成的(main.py:758-782)。

5.2 _fast_span:自动埋点的极简版

_fast_span(main.py:278)是给自动埋点用的精简装配—— 不支持消息格式化,直接 start_span 并包成 FastLogfireSpan:

# 真源码 main.py:284-285,符号 Logfire._fast_span
span = self._spans_tracer.start_span(name=name, attributes=attributes, **kwargs)
return FastLogfireSpan(span)

_span 的关键区别:_fast_span 进门就 start(注意它返回的 FastLogfireSpan 一构造就 attach 了 context,见 §6.2),因为自动埋点的调用点很热,越省越好。

5.3 _instrument_span_with_args:折中版

@instrument(extract_args=True) 想把函数参数记成属性,但又不想付 _span 的全部代价,于是有了 _instrument_span_with_args(main.py:290):支持消息格式化(logfire_format)和 JSON schema, 但最终仍落到 _fast_span(main.py:305)。三条路的定位一览:

路径消息格式化富类型属性何时 start用途
_span有(带 f-string 魔法)全支持延迟到 __enter__手写 logfire.span(...)
_instrument_span_with_args有(logfire_format)支持立即@instrument(extract_args=...)
_fast_span仅 OTel 原生类型立即自动埋点热路径

5.4 instrument() 入口:装饰器只是"选好一条开 span 的路"

Logfire.instrument(main.py:666)本身不开 span,它返回一个装饰器。有个方便的重载:直接 @logfire.instrument(不加括号)时 msg_template 会是被装饰的函数本身,于是先补上括号再套一层:

# 真源码 main.py:704-716,符号 Logfire.instrument(节选)
if callable(msg_template): # 用户写了 @logfire.instrument(没括号)
return self.instrument()(msg_template)
return instrument(self, tuple(self._tags), msg_template, span_name,
extract_args, record_return, allow_generator, new_trace, level=level)

真正的工厂 instrument(instrument.py:52)在**装饰时(而非每次调用时)**就用 get_open_span (instrument.py:73、定义 :125)预先决定:这个函数该用 _fast_span 还是 _instrument_span_with_args。 然后按函数是普通/协程/生成器分别包一层 wrapper(instrument.py:75-120),运行时 wrapper 里 with open_span(...) 就把 span 开起来。深入见第 5 章


6. LogfireSpan 的生命周期:延迟启动的秘密

_span() 交出的 LogfireSpan(main.py:3113)是个空壳——_span 属性是 None。它继承 ReadableSpan,还用 __getattr__(main.py:3136)把读属性的请求转发给内部真 span。真正的启动分四步。

6.1 三个动作:_start / _attach / _end

构造 LogfireSpan(...) _span = None, _token = None ← _span() 的产物

with span: → __enter__() main.py:3157
├─ _start() 真正 tracer.start_span,填上 _span main.py:3140
└─ _attach() 把 span 设为当前 context,存下 _token main.py:3151

... 期间可 set_attribute / set_level / record_exception ...

with 退出: → __exit__(exc...) main.py:3177
├─ _detach() 还原 context(detach _token) main.py:3170
├─ 若有异常:record_exception(escaped=True)
└─ _end() 补最终 JSON schema,然后 span.end() main.py:3163
  • _start(main.py:3140):幂等——_span 非 None 直接返回;否则 tracer.start_span,把 之前装配好的 _otlp_attributes_links_span_kind 全传进去。
  • _attach(main.py:3151):把新 span 设成 OTel 的"当前 span"(这样它内部的日志会成为它的子级), 并保存 _token 以便退出时精确还原。
  • _end(main.py:3163):若这期间用户加过属性(_added_attributes),重算一次 JSON schema 再写回(main.py:3166-3167),最后 span.end()

为什么延迟启动? 因为 logfire.span(...) 常被当普通对象先创建、稍后才进入 with。延迟 start 保证 span 的"开始时间"是真正进入代码块的时刻,而不是对象构造的时刻;也让你能在进入前 先 add_link 之类。

6.2 三种 span 类的分工

何时用start 时机特点
LogfireSpan手写 logfire.span(...)延迟到 __enter__全功能:可 set_attribute/set_level/add_link,__getattr__ 转发
FastLogfireSpan自动埋点 / instrument构造即 start+attach__slots__ 极简(main.py:3097),只有 __enter__/__exit__
NoopSpan被 level 过滤 或 内部出错时降级从不所有方法/属性都返回空,吞掉一切调用

FastLogfireSpan(main.py:3094)构造函数里就 context_api.attach(...)(main.py:3101)——它假设 "造出来就马上用",所以不玩延迟那套。

6.3 NoopSpan:让"关掉观测"也不改用户代码

NoopSpan(main.py:3278)的存在纯粹是为了API 一致性。它用 __getattr__ 返回一个吃掉所有参数 的 lambda(main.py:3296-3298),又实现了 message/tags 的 getter/setter(main.py:3307-3327), 于是下面这段代码在"被过滤/出错"时也照样跑,不抛异常:

with logfire.span('低于 min_level 的 span', _level='trace') as s:
s.set_attribute('x', 1) # NoopSpan 吞掉,无害
s.tags = ['a'] # 也不报错

这正是 §4.1 与 §4.6 里"宁可返回 NoopSpan 也不返回 None"设计的回报。

6.4 运行期改属性:set_attribute / set_level

LogfireSpan.set_attribute(main.py:3207)在装配好的 span 上补属性时,做了三件事:置 _added_attributes = True(触发 §6.1 里 _end 的 schema 重算)、更新 JSON schema、把值经 prepare_otlp_attribute 降格后写入。set_level(main.py:3257)则要分情况——span 还没 start 时改的是 _otlp_attributes 字典,已 start 就直接 set_attributes 到真 span。这个"没 start 改字典、 已 start 改 span"的分叉,_set_attribute(main.py:3270)、add_link(main.py:3226)都遵循, 是延迟启动带来的统一模式。


7. 巧妙之处(可带走的设计)

  • 门面 = 绑定方法别名,零额外抽象。 顶层函数和实例方法是同一段代码,既省心又让多实例 (with_tags/with_settings 造的)天然共用逻辑。with_settings(main.py:877)返回 Logfire 实例、复用同一个 _config,是"不可变配置对象"的干净范例。

  • 观测工具绝不拖垮业务:双保险。 _span 整体 try/except → NoopSpan(main.py:274), 生命周期方法几乎都挂 @handle_internal_errors(main.py:3139/3150/3162/3176/…,装饰器定义 utils.py:378)。任何内部错误都被吞进"内部错误日志",用户代码继续跑。

  • NoopSpan 用 duck typing 抹平"开/关"差异(main.py:3278),让"过滤掉的 span"和"真 span" 在调用方看来完全一样——省掉了到处 if span is not None 的判断。

  • 一次 f-string 抓帧、两处复用:_spanlog 都靠 inspect.currentframe() 把调用者的帧交给 格式化器(main.py:231:761),log 还额外判断"用户是直接调 log 还是走了 info 包装"来 多退一层帧(main.py:762-765)——细节虽小,却保证 f-string 里的局部变量总能被正确读到。


8. 边界与局限

  • 本章不含真正的 span 处理与导出。 _span() 只到"造出 LogfireSpan"为止;它交给的 _spans_tracerProxyTracerProvider 给的代理,真正的处理器/导出在 第 4 章

  • configure() 之前调用会怎样? _tracer_provider(main.py:179)首次访问时会 warn_if_not_initialized——没配置就发警告,且不产生真实遥测。源码里 NoopSpan 的 TODO (main.py:3290)也点明:未来"未配置"也想走 NoopSpan,但当前尚未如此。

  • f-string 魔法与 JSON schema 的内部不在这里——logfire_format_with_magicattributes_json_schema_properties 只在本章"接缝处"出现,展开在 第 2 章

  • 采样与脱敏本章只提到"哪一步接入"(sample_rate 属性、self._config.scrubber), 取舍逻辑见第 6 章


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

主题文件路径符号
默认实例 & 顶层别名logfire/__init__.py:34-36DEFAULT_LOGFIRE_INSTANCE / span / instrument
日志方法族绑定logfire/__init__.py:82-91log/trace/info/error/exception
接线员类logfire/_internal/main.py:151Logfire
tracer 懒加载logfire/_internal/main.py:196Logfire._spans_tracer / _logs_tracer
span 装配核心logfire/_internal/main.py:207Logfire._span
level 过滤短路logfire/_internal/main.py:219-223_span(level 分支)
快车道:自动埋点logfire/_internal/main.py:278Logfire._fast_span
快车道:带参 instrumentlogfire/_internal/main.py:290Logfire._instrument_span_with_args
日志(零时长 span)logfire/_internal/main.py:718Logfire.log
span 入口logfire/_internal/main.py:562Logfire.span
instrument 入口logfire/_internal/main.py:666Logfire.instrument
派生实例logfire/_internal/main.py:837 / :877with_tags / with_settings
属性降格logfire/_internal/main.py:3340 / :3348prepare_otlp_attributes / prepare_otlp_attribute
全功能 spanlogfire/_internal/main.py:3113LogfireSpan
生命周期logfire/_internal/main.py:3140/:3151/:3157/:3163/:3177_start/_attach/__enter__/_end/__exit__
运行期改属性logfire/_internal/main.py:3207 / :3257set_attribute / set_level
极简 spanlogfire/_internal/main.py:3094FastLogfireSpan
空实现 spanlogfire/_internal/main.py:3278NoopSpan
用户栈定位logfire/_internal/stack_info.py:78 / :112get_user_stack_info / is_user_code
level 转数字logfire/_internal/constants.py:92log_level_attributes
属性 key 常量logfire/_internal/constants.py:106-118ATTRIBUTES_MESSAGE_KEY / _MESSAGE_TEMPLATE_KEY / _SPAN_TYPE_KEY / _TAGS_KEY
instrument 工厂logfire/_internal/instrument.py:52 / :125instrument / get_open_span