三个相连的方块,依次标注观察、决策与行动。

链路采样减少数据量,但不会固定成本上限

链路采样决定哪些观测数据进入后续追踪管线,可以减少导出流量和存储量,但配置的百分比并不是字节数、处理器开销或费用的严格上限。不同请求产生的跨度数量不同,属性大小不同,业务流量也不断变化。即使一直保留百分之一的数据,如果请求流量突然增加十倍,被保留的数据量仍然可能大致增加十倍。不能只看到比例很低,就判断采集系统已经有了确定的成本边界。 头部采样在链路开始附近作出决定。基于链路标识的比例采样器,需要标识满足算法所依赖的随机性要求。其概率描述的是合适流量总体中的选择规律,并不承诺每十六个请求都恰好保留一半,也不保证每条业务路由都得到同等覆盖。对于数量很少的请求和罕见故障,即使整体采样百分比看起来正常,保留下来的实例仍可能不足以解释问题。 让同一项决定跨边界保持一致 如果系统期望同一条链路沿用上游决定,下游插桩就应遵循已经接受的父上下文。Python SDK 的 ParentBased 对根跨度调用配置的根采样器,并为已采样、未采样的父跨度应用对应策略。因此,把 TraceIdRatioBased(0.5) 包在父级策略中,与每个下游跨度都独立进行一次抽样不同。上下文传播方式和接收端采样配置必须配合,不能只修改入口的百分比,而忽略其余服务如何解释传播标志。 未被采样的链路仍然要执行业务代码,也仍可能涉及上下文处理和插桩开销。省掉导出并不意味着整个请求没有成本。反过来,即使父级决定得到保留,也不能保证每个子操作都能在界面中出现:某个库可能没有插桩,服务可能丢失上下文,导出器也可能失败。采样一致性只解决数据缺失的一部分原因,无法独自证明整个观测传输过程都可靠。 坏掉的样子 请求在开始时可能一切正常,直到后半程才失败。如果头部采样器早已决定丢弃,应用之后设置错误状态,并不能把未记录的跨度补回来。尾部采样可以先收集更多跨度,再依据整条链路的信息判断,但需要缓冲数据、将相关跨度路由到一起,并处理迟到或缺失的数据。它同样无法重建到达自身之前就被上游舍弃的跨度,因此不能把尾部规则理解成无条件保留所有失败的保证。 另一种误用,是直接拿带有选择偏差的追踪数据计算业务可靠性。专门保留慢请求或错误请求的策略,已经改变了观察总体。应另外维护分母清晰的请求结果指标,并在排查说明中交代采样策略。要判断费用是否受控,还需要观察实际导出的跨度数和字节数、队列情况、采集器资源与数据保留期限。一个配置项只能说明计划采用怎样的选择规则,不能替代实际负载与费用证据。 快速验证 创建 Python 虚拟环境并安装 opentelemetry-sdk==1.35.0,将代码保存为 trace-sampling.py,再使用该环境运行。本次执行环境是 Python 3.14.4。代码中的自定义标识生成器专门服务于确定性测试:十六个标识被有意安排在这个 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() 十六个根操作分别启动两个子操作,最终应导出八条链路、二十四个跨度,而三十二个子操作全部实际执行。结果恰好为一半,是测试输入被精确安排的产物,不能推广成随机业务流量中的逐批保证。编号十五的根操作被故意放在不保留的集合内;后来设置错误状态,也不会令它转成记录状态或出现在导出结果里。断言直接验证这一限制,而不是通过几次随机运行估计采样比例。 ...

十月 23, 2027 · 2 分钟 · XB Field Notes