跳到主要内容

采样与脱敏:量与安全的两道闸

30 秒导读: 你的应用把每一次请求都变成一棵 trace 往 Logfire 送——但你既付不起「全量存」的账单,也不想把用户的密码、token 混进去。这一章讲两个横切关注点:采样(控制"送多少")和脱敏(控制"送的内容里有没有敏感信息")。两者都以旁路闸门的形式挂在导出管线上,不需要你改一行业务代码。

本章是 logfire 系列的第 6 章,专讲这两道闸。管线本身(span 怎么被加工、怎么可靠导出)在 04-processing-and-export.md;配置怎么把管线搭起来在 03-config-and-pipeline-assembly.md。本章与 04 的唯一重叠,只是指出脱敏器挂在哪个位置;它的实现细节全在这里。


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

先把两个词用大白话点破。

  • 采样(sampling): 决定"这条 trace 到底送不送、送多少"。生产环境一天可能产生上亿条 trace,全存又贵又吵。采样就是有选择地丢一部分
  • 脱敏(scrubbing): 决定"送出去的数据里,敏感字段要不要抹掉"。日志里混进 passwordapi_key、信用卡号是灾难。脱敏就是在数据离开进程前把它们涂黑

它们的共同点,是本章标题说的"横切":

横切关注点(cross-cutting concern) = 一个"到处都要管、但又不属于任何单个业务功能"的事。它不该散落进你的每个 logfire.span(...) 调用里,而应集中在一处、对所有 span 统一生效。

两道闸都满足一句话目标:在不改业务代码的前提下,控制数据量与敏感信息。 你只在 logfire.configure(...) 里配一次,之后所有 span 自动过闸。

一句话直觉

把 Logfire 的导出管线想成一条从工厂到货车的传送带:

  • 脱敏 = 传送带尽头的涂黑工位,每个包裹出厂前扫一遍,敏感标签一律涂黑。
  • 采样 = 装车口的分拣闸。头部采样是"进传送带前先掷骰子,大部分直接扔掉";尾部采样更聪明,是"先把一整箱货攒在暂存区,等看清里面有没有次品(出错/太慢),有才装车,没有就整箱丢"。

2. 两道闸在管线里的位置(顶层全景)

先看这两道闸挂在哪。下面这张图是 04 里那条导出管线的采样/脱敏视角,从上到下是一个 span 结束后的流向:

一个 span on_end


┌──────────────────────────────────────────────┐
│ CheckSuppressInstrumentationProcessorWrapper │ (略)
├──────────────────────────────────────────────┤
│ MainSpanProcessorWrapper │ ← 【脱敏闸】就在这
│ on_end: │ _tweak_*(改写各种 span)
│ ... 一串 span 改写 ... │ self.scrubber.scrub_span(span_dict) ★
├──────────────────────────────────────────────┤
│ root_processor: │ ← 【尾部采样闸】(可选)
│ 若配了 sampling.tail → │ TailSamplingProcessor
│ 先把整条 trace 缓冲进 TraceBuffer, │ 缓冲 → 判定 → 保留/丢弃
│ 达标才放行,不达标整条丢 │
│ 否则 → main_multiprocessor 直接放行 │
└──────────────────────────────────────────────┘


批量导出到 Logfire 后端

另外:【头部采样闸】不在这条链上,而在更上游——
TracerProvider(sampler=ParentBasedTraceIdRatio(head))
span 还没真正创建时就先掷骰子决定要不要建。

三个闸的位置和职责,一张表看清:

在管线的哪一层何时决策决策依据源码锚点
头部采样TracerProvidersamplerspan 创建前trace_id 与随机比率config.py:1180-1194
尾部采样root_processor(最外层处理器)整条 trace 收集后span 的 level / duration_tail_sampling.py:147
脱敏MainSpanProcessorWrapper.on_end每个 span 导出前属性 key 是否命中敏感正则processor_wrapper.py:84

关键区别一句话:头部采样"先掷骰子后干活"省算力,但盲目;尾部采样"先干活后决定"能精准留下出错/慢的 trace,代价是要把整条 trace 缓冲在内存里。


3. 采样:头部与尾部两种策略

3.1 一个统一入口:SamplingOptions

用户对采样的所有配置都收敛到一个 dataclass 上,你在 logfire.configure(sampling=...) 里传它:

# 示意,非源码 —— 用户视角
import logfire

logfire.configure(
sampling=logfire.SamplingOptions(
head=0.3, # 头部:随机只保留 30% 的 trace
tail=my_tail_callback, # 尾部:对每个 span 回调,返回该 trace 的保留概率
)
)

真实定义在 logfire/sampling/_tail_sampling.py:78SamplingOptions:两个字段 head(默认 1.0,即全留)和 tail(默认 None,即不做尾采样)。head 既可以是 0.0~1.0 的浮点,也可以直接是一个 OpenTelemetry 的自定义 Sampler(_tail_sampling.py:84-93)。

3.2 头部采样:创建前掷一次骰子

它要解决的小问题: 有些流量根本不值得追踪(健康检查、静态资源)。最省的做法是连 span 都别建——省下后续所有加工/导出的开销。

思路: 用 trace_id 本身当随机源。同一条 trace 的所有 span 共享一个 trace_id,所以"按 trace_id 算一个比率"能保证一整条 trace 要么全建、要么全不建,不会出现半棵树。

真实实现config.py:1180-1194_initialize:

# logfire/_internal/config.py:1180 (LogfireConfig._initialize)
head = self.sampling.head
sampler: Sampler | None = None
if isinstance(head, (int, float)):
if head < 1:
sampler = ParentBasedTraceIdRatio(head) # 只有 head<1 才真正装采样器
else:
sampler = head # 用户直接给了 Sampler
tracer_provider = SDKTracerProvider(sampler=sampler, ...)

两个细节值得记:

  • head < 1 才装采样器——head == 1.0(默认)时 sampler 保持 None,等于零开销全量放行。
  • ParentBasedTraceIdRatio 是 OTel 内置采样器:根 span 按 head 比率掷骰子,子 span 跟随父 span 的决定。这就是"整条 trace 同进退"的保证。

3.3 尾部采样:先攒齐,再决定"只留出错或慢的"

头部采样有个致命缺点:掷骰子那一刻,你还不知道这条 trace 会不会出错、会不会慢。 于是最该留下的"出错/慢"trace,可能刚好被随机丢了。

尾部采样反过来:先把整条 trace 都收下来攒着,等看清它的"成色"再决定。 这是本章工程含量最高的一支。

怎么读下面这张图

从上到下是一个 span 的 on_start/on_end 进入 TailSamplingProcessor 后的判定流。核心状态是一个 dict[trace_id → TraceBuffer]:某条 trace 只要还在字典里,就说明它"还没达标、正被缓冲";一旦达标或结束,就从字典里移除。

span 事件 (on_start / on_end)

┌─────────┴──────────┐
│ 这条 trace 还有 │ 否 → 早已定过生死 → 直接放行/丢弃
│ TraceBuffer 吗? │
└─────────┬──────────┘
是 │ 把此 span 存进 buffer(started/ended)

┌────────────────────┐
│ check_span: │
│ get_tail_sample_rate │ ← 用户回调,看这个 span 的 level/duration
│ → 得到 rate ∈[0,1] │
│ check_trace_id_ratio │ ← 按 trace_id 判定是否落在保留比率内
└─────────┬──────────┘
达标(该留)│ 不达标
▼ │
drop_buffer(从字典移除) │
push_buffer(把缓冲的 │
span 全部放行导出) ▼
继续缓冲,等下一个 span
(若根 span 结束仍不达标
→ 整条 trace 丢弃,永不导出)

一步步拆解

① 缓冲:TraceBuffer 把一整条 trace 攒在内存。 _tail_sampling.py:23TraceBuffer 存三样:started(所有 on_start 的参数)、ended(所有已结束的 span)、first_span(根 span,用来算 trace_id 和起始时间)。根 span 一到,就在 on_start 里为这条 trace 新建一个 buffer(_tail_sampling.py:187-188)。

② 判定的信息载体:TailSamplingSpanInfo。 每次判定,都把当前 span 包成 _tail_sampling.py:41TailSamplingSpanInfo 交给用户回调。它暴露两个算好的属性:

属性含义源码
level该 span 的日志级别(error/warn/…)_tail_sampling.py:63-66,来自 SpanLevel.from_span
duration从整条 trace 起点到本 span 的秒数_tail_sampling.py:68-74,纳秒差 ÷ ONE_SECOND_IN_NANOSECONDS

③ 内置策略:level_or_duration —— "只保留出错或慢的 trace"。 大多数人不会手写回调,而用现成的 SamplingOptions.level_or_duration(_tail_sampling.py:107)。它的逻辑一目了然(_tail_sampling.py:130-137):

# logfire/sampling/_tail_sampling.py:130 (level_or_duration 内部)
def get_tail_sample_rate(span_info: TailSamplingSpanInfo) -> float:
if duration_threshold is not None and span_info.duration > duration_threshold:
return 1.0 # 太慢 → 必留
if level_threshold is not None and span_info.level >= level_threshold:
return 1.0 # 级别够高(如 error) → 必留
return background_rate # 其余 → 按背景比率(默认 0.0,即丢)

默认 level_threshold='notice'duration_threshold=5.0 秒、background_rate=0.0:普通又快的 trace 全丢,只要有一条 span 到达 notice 级别、或整条 trace 超过 5 秒,就整条保留。 构造时还会校验 0.0 <= background_rate <= head <= 1.0(_tail_sampling.py:127-128)——尾采样留下的比例不该超过头采样已经放行的比例。

④ 用 trace_id 做最终的概率判定。 回调给出 rate 后,check_span(_tail_sampling.py:244)不是再掷一次随机数,而是复用 check_trace_id_ratio(_tail_sampling.py:142):

# logfire/sampling/_tail_sampling.py:142
def check_trace_id_ratio(trace_id: int, rate: float) -> bool:
# Based on TraceIdRatioBased.should_sample.
return (trace_id & TraceIdRatioBased.TRACE_ID_LIMIT) < TraceIdRatioBased.get_bound_for_rate(rate)

用 trace_id 而非新随机数,保证同一条 trace 的每个 span 判定结果一致——决定只会从"不确定"翻向"确定保留",不会来回抖动。

⑤ 放行还是丢弃。 check_span 一旦判定该留(sampled=True),就 drop_buffer 把这条 trace 从字典里删掉(_tail_sampling.py:250-253)、再 push_buffer 把缓冲的所有 ended span 补送给下游(_tail_sampling.py:258-269)。反之,如果根 span 都结束了还没达标,on_end 会把 buffer 直接 pop 丢掉(_tail_sampling.py:227-230)——这条 trace 就此蒸发,永不导出。

一个巧妙的时序细节:on_start 立刻放行,只缓冲 on_end

TailSamplingProcessor 并不是把所有东西都憋住。看 on_start(_tail_sampling.py:198-203)的注释:主处理器的 on_start 总是立刻调用,因为大多数处理器在 on_start 里只是设置属性,在采样决策前做也无害。真正被缓冲、等决策后才补发的是 on_end

例外是像 PendingSpanProcessor 这种会在 on_start新建 span 的处理器——它们被单独归为 deferred_processor,on_starton_end 都要缓冲(_tail_sampling.py:153-156),否则会在决策前就凭空造出待定 span。高级用户可以给自己的处理器类设 tail_sampling_defer_on_start = True 来加入这一队(见 config.py:1201)。

它在管线里怎么被装上

尾采样器在 _initialize 里被套在整条主处理链的最外层(config.py:1384-1394):

# logfire/_internal/config.py:1383
root_processor: SpanProcessor = main_multiprocessor
if self.sampling.tail:
...
root_processor = TailSamplingProcessor(
main_multiprocessor, # 达标后要放行去的地方
self.sampling.tail, # 用户的 get_tail_sample_rate 回调
deferred_processor=deferred_multi,
)

没配 tail 时,root_processor 就是裸的 main_multiprocessor,零尾采样开销。


4. 脱敏:在导出前把敏感值涂黑

4.1 它要解决的小问题

你写 logfire.info('login', extra={'password': pw}),或某个第三方库把 Authorization 头塞进了 span 属性——这些不该出现在可观测后端里。脱敏的任务:在 span 离开进程前,扫一遍它的属性/事件,命中敏感 key 就把值替换成 [Scrubbed due to '...']

这是一层兜底防线(PII redaction):不指望你记得每处都手动过滤,而是集中在一处对所有 span 统一把关。

4.2 默认就认得的敏感词

scrubbing.py:38DEFAULT_PATTERNS 是一组正则,大小写不敏感:

# logfire/_internal/scrubbing.py:38 (节选)
DEFAULT_PATTERNS = [
'password', 'passwd', 'mysql_pwd', 'secret',
r'auth(?!ors?\b)', # 匹配 auth,但排除 "author/authors"
'credential', 'private[._ -]?key', 'api[._ -]?key',
'session', 'cookie', 'social[._ -]?security', 'credit[._ -]?card',
'logfire[._ -]?token', r'pylf_v\d+_', # Logfire 自己的 token 格式
# csrf / xsrf / jwt / ssn —— 要求被词边界或下划线包裹,避免误伤 base64 乱码
]

两个细节体现"少误杀"的用心:auth 用负向前瞻 (?!ors?\b) 排掉 author;jwt/ssn 这类短缩写要求前后是词边界或 _,以免在一大坨随机字符里瞎命中(scrubbing.py:53-63)。用户还能通过 ScrubbingOptions.extra_patterns 追加自己的正则,通过 callback 自定义"命中后怎么替换"(scrubbing.py:94-111)。

4.3 谁在做事:Scrubber vs SpanScrubber

脱敏拆成两个类,职责分明:

角色生命周期源码
Scrubber长期存在,持有编译好的正则和 callback配置期建一次scrubbing.py:202
SpanScrubber干活的,持有"本次 span 被涂黑了哪些"的可变状态每个 span 新建一个scrubbing.py:240

Scrubber.scrub_span(scrubbing.py:215)每次都 new 一个 SpanScrubber 来做实际工作——因为要在处理单个 span 时累积"涂黑记录"(scrubbed 列表),用一次性对象最干净。涂黑后,记录会被序列化进 span 的一个专门属性,方便后端标记"这里被脱敏过"(scrubbing.py:222-232)。

有个针对性豁免:instrumentation scope 是 logfire.openai / logfire.anthropic 的 span 整条跳过脱敏(scrubbing.py:216-218)——这些是 LLM 调用,内容就是要看的。

4.4 核心:递归遍历 + 白名单兜底

真正的算法在 SpanScrubber.scrub(scrubbing.py:305),它对属性、事件、链接递归下钻,对 str / Sequence / Mapping 分别处理:

# logfire/_internal/scrubbing.py:305 (SpanScrubber.scrub,核心分支节选)
if isinstance(value, str):
if match := self._pattern.search(value):
if match.span() == (0, len(value)):
return value # 整串就等于 'password' 这种 → 视为安全,不涂
try:
value = json.loads(value) # 是 JSON 字符串?解开再递归扫里面
except json.JSONDecodeError:
return self._redact(ScrubMatch(path, value, match)) # 普通串 → 涂黑
else:
return json.dumps(self.scrub(path, value))
elif isinstance(value, Sequence):
return [self.scrub(path + (i,), x) for i, x in enumerate(value)] # 逐元素
elif isinstance(value, Mapping):
for k, v in value.items():
if k in BaseScrubber.SAFE_KEYS: result[k] = v # 白名单:原样保留
elif match := self._pattern.search(k): # key 命中 → 涂 value
result[k] = self._redact(ScrubMatch(path + (k,), v, match))
else: result[k] = self.scrub(path + (k,), v)

几处设计值得记:

  • 递归下钻: 嵌套的 list / dict / 甚至 JSON 字符串都会被解开逐层扫,敏感值藏多深都跑不掉。
  • 白名单优先(SAFE_KEYS,scrubbing.py:117): 一批 key 明确"即使命中正则也安全",直接原样保留——包括 Logfire 自己的 message/schema/level 字段、常见 http.*/db.* 语义约定、以及 GenAI 语义约定(输入输出消息、token 数等)。这是避免把有用字段误涂的关键。
  • 整串等于关键词 → 不涂: 值本身就字面是 'password'(而非 password=123)时视为安全(scrubbing.py:313-316),因为它显然没携带真正的机密。
  • 替换动作在 _redact(scrubbing.py:340): 有 callback 就交给用户;否则替换成 f'[Scrubbed due to {matched_substring!r}]',并把 (path, matched_substring) 记进 scrubbed 列表。

4.5 它挂在管线的哪一步

脱敏器由配置期创建:config.py:873-874,scrubbing 不为 False 时建 Scrubber,否则用 NOOP_SCRUBBER(什么都不做)。它作为 MainSpanProcessorWrapper 的一个字段被传入(config.py:1401)。

真正的调用点在 processor_wrapper.py:84,MainSpanProcessorWrapper.on_end 的一串 span 改写的最后一步:

# logfire/_internal/exporters/processor_wrapper.py:71 (MainSpanProcessorWrapper.on_end)
span_dict = span_to_dict(span)
_tweak_asgi_send_receive_spans(span_dict)
... # 一串 _tweak_*/_transform_* 改写
self.scrubber.scrub_span(span_dict) # ★ 脱敏:放在所有改写之后、导出之前
span = ReadableSpan(**span_dict)

放最后一步是有讲究的:先让各种 _tweak_* / _transform_* 把 span 整理成最终形态,脱敏在"临出门"那一刻统一扫描,保证没有任何后续步骤能再往里塞未脱敏的值。管线其余部分见 04-processing-and-export.md

补充:f-string 里用户显式写进消息的字段(如 logfire.info(f'... {password} ...'))走的是另一条路 MessageValueCleaner(scrubbing.py:350),check_keys=False 时不按 key 脱敏——因为你明摆着要把它记进消息。但它仍可能作为属性被上面的 scrub_span 涂掉,两条路互不影响。f-string 的机制见 02-fstring-magic-and-schema.md


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

  • 用 trace_id 当采样随机源,而非现掷随机数。 头部(ParentBasedTraceIdRatio)和尾部(check_trace_id_ratio,_tail_sampling.py:142)都这么做,天然保证"一整条 trace 同进退",决策幂等、不抖动。
  • 尾采样"缓冲 on_end、放行 on_start"的分层。 只缓冲真正需要延后的东西(_tail_sampling.py:198-203),把纯设属性的 on_start 立即放行,既省内存又不误伤依赖 on_start 的处理器。
  • 脱敏的白名单先于正则。 SAFE_KEYS(scrubbing.py:117)让"看着敏感、其实有用"的字段免疫,把误杀率压下来——脱敏最怕的不是漏,而是把有用数据也涂了。
  • 脱敏留痕。 每次涂黑都把 (path, matched_substring) 记进 span 的专门属性(scrubbing.py:346223-232),后端能明确知道"这里被处理过",而不是数据无声消失。
  • 两道闸都能零成本关闭。 head==1.0 不装采样器、tail is None 不套 TailSamplingProcessorscrubbing is FalseNOOP_SCRUBBER——不用的功能不产生任何运行时开销。

6. 边界与局限(诚实)

  • 尾采样吃内存。 SamplingOptions.tail 的文档明说:整条 trace 的每个 span 都要缓冲在内存直到被保留或丢弃(_tail_sampling.py:103-105),大 trace 会占很多内存。
  • 尾采样按"根 span 结束"回收 buffer。 on_end 里靠 span.parent is None 判断 trace 完成、再 pop buffer(_tail_sampling.py:227-230)。若根 span 迟迟不结束,buffer 就一直挂着。
  • 脱敏是正则匹配,不是语义理解。 它认的是 key 名 / 字符串内容命中模式;一个叫 data 的字段里塞了密码、且值里不含任何敏感词,就扫不出来。它是兜底,不是万能。
  • LLM span 整条豁免脱敏(scrubbing.py:216-218)——logfire.openai/logfire.anthropic 的内容不脱敏,若其中混入了真实 PII,这条防线不拦。
  • BoundedAttributes 会静默丢弃类型不符的 callback 返回值(scrubbing.py:257-262 的 TODO 注释坦承这点),自定义 callback 返回怪类型时可能悄悄失效。

7. 横向对比

同属 ai-agent-reference 货架的可观测方向,采样/脱敏是"数据治理"的共性关切。Logfire 的取舍有两个特点:

  • 尾采样内建"level_or_duration"这一现成策略(_tail_sampling.py:107),把"只留出错或慢的 trace"这个最常见诉求做成一行配置,而不是让用户自己写回调。
  • 脱敏走"默认开 + 白名单"路线:开箱即认十几类敏感词,再用一大张 SAFE_KEYS 防误杀——偏向"宁可默认拦、也别让用户忘了配"。

这两点都体现 Logfire 的总基调:是 OpenTelemetry 之上一层有主见的封装(见 index.md03-config-and-pipeline-assembly.md),把 OTel 里需要手搓的横切逻辑做成默认值。


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

主题文件路径符号名
采样统一配置入口logfire/sampling/_tail_sampling.py:78SamplingOptions
内置尾采样策略(level/duration)logfire/sampling/_tail_sampling.py:107SamplingOptions.level_or_duration
尾采样处理器logfire/sampling/_tail_sampling.py:147TailSamplingProcessor
trace 缓冲结构logfire/sampling/_tail_sampling.py:23TraceBuffer
判定信息载体 / level / durationlogfire/sampling/_tail_sampling.py:41,63,68TailSamplingSpanInfo
缓冲与放行时序logfire/sampling/_tail_sampling.py:178,213,258on_start / on_end / push_buffer
达标判定logfire/sampling/_tail_sampling.py:244,142check_span / check_trace_id_ratio
头部采样装配logfire/_internal/config.py:1180-1194LogfireConfig._initialize(ParentBasedTraceIdRatio)
尾采样装入管线logfire/_internal/config.py:1383-1394_initialize(root_processor)
默认敏感词logfire/_internal/scrubbing.py:38DEFAULT_PATTERNS
脱敏配置logfire/_internal/scrubbing.py:94ScrubbingOptions
脱敏器(长期)logfire/_internal/scrubbing.py:202Scrubber
脱敏工作类(一次性)logfire/_internal/scrubbing.py:240,305,340SpanScrubber / scrub / _redact
脱敏白名单logfire/_internal/scrubbing.py:117BaseScrubber.SAFE_KEYS
脱敏在管线的调用点logfire/_internal/exporters/processor_wrapper.py:62,84MainSpanProcessorWrapper.on_end
脱敏器的创建logfire/_internal/config.py:873-874_LogfireConfigData(self.scrubber)