链路采样决定哪些观测数据进入后续追踪管线,可以减少导出流量和存储量,但配置的百分比并不是字节数、处理器开销或费用的严格上限。不同请求产生的跨度数量不同,属性大小不同,业务流量也不断变化。即使一直保留百分之一的数据,如果请求流量突然增加十倍,被保留的数据量仍然可能大致增加十倍。不能只看到比例很低,就判断采集系统已经有了确定的成本边界。

头部采样在链路开始附近作出决定。基于链路标识的比例采样器,需要标识满足算法所依赖的随机性要求。其概率描述的是合适流量总体中的选择规律,并不承诺每十六个请求都恰好保留一半,也不保证每条业务路由都得到同等覆盖。对于数量很少的请求和罕见故障,即使整体采样百分比看起来正常,保留下来的实例仍可能不足以解释问题。

让同一项决定跨边界保持一致

如果系统期望同一条链路沿用上游决定,下游插桩就应遵循已经接受的父上下文。Python SDK 的 ParentBased 对根跨度调用配置的根采样器,并为已采样、未采样的父跨度应用对应策略。因此,把 TraceIdRatioBased(0.5) 包在父级策略中,与每个下游跨度都独立进行一次抽样不同。上下文传播方式和接收端采样配置必须配合,不能只修改入口的百分比,而忽略其余服务如何解释传播标志。在不可信边界,还应决定是否接受调用方的采样标志;W3C 将它视为提示,不是无限消耗追踪资源的授权。

未被采样的链路仍然要执行业务代码,也仍可能涉及上下文处理和插桩开销。省掉导出并不意味着整个请求没有成本。反过来,即使父级决定得到保留,也不能保证每个子操作都能在界面中出现:某个库可能没有插桩,服务可能丢失上下文,导出器也可能失败。采样一致性只解决数据缺失的一部分原因,无法独自证明整个观测传输过程都可靠。

坏掉的样子

请求在开始时可能一切正常,直到后半程才失败。如果头部采样器早已决定丢弃,应用之后设置错误状态,并不能把未记录的跨度补回来。尾部采样可以先收集更多跨度,再依据整条链路的信息判断,但需要缓冲数据、将相关跨度路由到一起,并处理迟到或缺失的数据。它同样无法重建到达自身之前就被上游舍弃的跨度,因此不能把尾部规则理解成无条件保留所有失败的保证。

另一种误用,是直接拿带有选择偏差的追踪数据计算业务可靠性。专门保留慢请求或错误请求的策略,已经改变了观察总体。应另外维护分母清晰的请求结果指标,并在排查说明中交代采样策略。要判断费用是否受控,还需要观察实际导出的跨度数和字节数、队列情况、采集器资源与数据保留期限。一个配置项只能说明计划采用怎样的选择规则,不能替代实际负载与费用证据。

快速验证

创建 Python 虚拟环境并安装 opentelemetry-sdk==1.35.0,将代码保存为 trace-sampling.py,再使用该环境运行。本轮以 Python 3.12.14 和这个固定 SDK 版本重新执行。代码中的自定义标识生成器专门服务于确定性测试:十六个标识被有意安排在这个 SDK 版本的比例判断阈值两侧。生产环境不能照搬这种顺序标识方案,否则将破坏采样器依赖的随机分布假设。这里追求的是可重复核对已知输入的结果。

import json
from collections import Counter

from opentelemetry.context import Context
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.sdk.trace.id_generator import IdGenerator
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
from opentelemetry.trace import Status, StatusCode
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator


class TestIds(IdGenerator):
    # Controlled test inputs only; production IDs need suitable randomness.
    def __init__(self):
        self.trace_index = 0
        self.span_index = 0

    def generate_trace_id(self):
        index = self.trace_index
        self.trace_index += 1
        return ((index + 1) << 64) | (index * (1 << 60) + 1)

    def generate_span_id(self):
        self.span_index += 1
        return self.span_index


exporter = InMemorySpanExporter()
provider = TracerProvider(sampler=ParentBased(TraceIdRatioBased(0.5)),
                          id_generator=TestIds())
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer("sampling-lab")
executed_children = 0
late_error_recording = None
for index in range(16):
    with tracer.start_as_current_span(f"root-{index}", context=Context()) as root:
        for child in range(2):
            with tracer.start_as_current_span(f"child-{index}-{child}"):
                executed_children += 1
        if index == 15:
            late_error_recording = root.is_recording()
            root.set_status(Status(StatusCode.ERROR, "error discovered after start"))
assert provider.force_flush()
spans = exporter.get_finished_spans()
counts = Counter(span.context.trace_id for span in spans)
assert len(counts) == 8 and set(counts.values()) == {3} and len(spans) == 24
assert {s.name for s in spans if s.parent is None} == {f"root-{i}" for i in range(8)}
assert executed_children == 32 and late_error_recording is False
assert not any(span.name == "root-15" for span in spans)

exporter.clear()
propagator = TraceContextTextMapPropagator()
for flag, label in (("01", "sampled"), ("00", "unsampled")):
    # High trace-ID bits do not override an accepted remote parent's flag.
    header = f"00-1111111111111111ffffffffffffffff-2222222222222222-{flag}"
    context = propagator.extract({"traceparent": header}, context=Context())
    with tracer.start_as_current_span(label, context=context):
        with tracer.start_as_current_span(label + "-child"):
            pass
assert provider.force_flush()
remote_spans = exporter.get_finished_spans()
assert {span.name for span in remote_spans} == {"sampled", "sampled-child"}
assert next(span for span in remote_spans if span.name == "sampled").parent.is_remote
print(json.dumps({"controlled_roots": 16, "exported_traces": len(counts),
                  "exported_spans": len(spans), "executed_child_operations": executed_children,
                  "late_error_was_recording": late_error_recording,
                  "remote_parent_exported_spans": len(remote_spans)}, indent=2))
provider.shutdown()

十六个根操作分别启动两个子操作,最终应导出八条链路、二十四个跨度,而三十二个子操作全部实际执行。结果恰好为一半,是测试输入被精确安排的产物,不能推广成随机业务流量中的逐批保证。编号十五的根操作被故意放在不保留的集合内;后来设置错误状态,也不会令它转成记录状态或出现在导出结果里。断言直接验证这一限制,而不是通过几次随机运行估计采样比例。

第二阶段分别提取两个远端父请求头,它们使用相同的高位链路标识,但采样标志不同。虽然该标识超过根比例采样器的保留阈值,已采样父级对应的两个跨度仍然被导出,未采样父级对应的跨度则没有导出。这说明接收端采用了父级决定。本实验没有运行独立采集器,也没有实施尾部采样。投入真实服务时,应分别验证新根请求与已接受远端上下文的行为,再针对实际业务流量检查数据量和诊断覆盖是否满足要求。

来源