在分布式系统中,一次用户请求往往会穿越多个服务,每一个服务都会产生若干Span来记录执行细节。采样决策决定了这条链路是否被完整记录到后端。若上游服务已经判定某次调用无需采样,但下游Ruby服务因为读不到这个信号而自行决定采样,就会出现链路断裂或数据冗余。因此,Ruby必须有一套可靠的机制,把上游的采样决策沿着网络API请求继续向下游传播。

采样决策与上下文传播的基本概念
采样决策通常由链路最顶端的服务在请求入口处做出,结果会保存在追踪上下文里。OpenTelemetry等框架普遍采用W3C定义的traceparent与tracestate请求头承载这些信息。traceparent中包含trace标识、父Span标识以及标记位,其中的采样标志(traceflags)直接告知接收方当前链路是否被采样。tracestate则可携带供应商自定义的决策信息。
对于Ruby程序来说,传播机制的核心任务只有两件:在作为客户端发起请求时,把当前上下文里的采样决策写进请求头;在作为服务端收到请求时,从请求头里解析出决策并设置为当前上下文,使后续创建的Span自动继承。只有双向都做对,决策才能贯穿整条链。
为什么不能让每个服务独立决策
如果每一个Ruby服务都根据本地采样率独立抛硬币决定采不采,那么一条本应完整的调用链可能在前两个服务被采样、在第三个被丢弃。排障时你看到的前半段链路没有后续,无法还原真实路径。统一由上游传播决策,是为了保证整条链路要么全采、要么全不采,降低存储成本的同时保留可用性。
另外,独立决策还会造成下游服务无谓地序列化、发送Span数据,消耗CPU与网络。当上游明确不采样时,下游直接跳过记录是最经济的做法。这也是为什么W3C与OpenTelemetry都把传播采样标志作为强制规范。
Ruby客户端如何注入采样决策
在Ruby里使用OpenTelemetry SDK时,HTTP客户端侧注入一般借助SDK提供的传播器(propagator)。以常用的Net::HTTP为例,我们在发送请求前,从当前上下文取出Span或链路状态,调用inject方法把字段写入请求头。下面代码展示了一次带采样传播的外发GET请求。
require 'net/http'
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'opentelemetry/instrumentation/net/http'
OpenTelemetry::SDK.configure do |c|
c.service_name = 'ruby-client-demo'
c.use 'OpenTelemetry::Instrumentation::Net::HTTP'
end
# 假设已经在入口处创建了根Span并做出采样决策
tracer = OpenTelemetry.tracer_provider.tracer('demo')
tracer.in_span('outbound-call') do |span|
uri = URI('http://192.168.0.1:8080/api/order')
req = Net::HTTP::Get.new(uri)
# 把当前上下文(含采样决策)注入到请求头
OpenTelemetry.propagation.inject(req)
res = Net::HTTP.start(uri.host, uri.port) do |http|
http.request(req)
end
puts res.body
end
上面代码中,OpenTelemetry.propagation.inject(req)会读取当前活动的Span上下文,并把traceparent等头写入req。如果当前链路被判定为不采样,traceflags里的采样位就是未置位,下游收到后也应放弃记录。这个过程对业务代码透明,只要保证在请求发出前调用注入即可。
若你使用Faraday或HTTParty等其他客户端,思路完全一致:在构建请求对象之后、真正连接之前,调用同一个注入接口。需要注意的是,某些老旧封装可能自行覆盖请求头,所以要确认注入发生在最后一步,避免被清空。
手动构造请求头的情况
当Ruby服务通过低层接口(例如直接用TCPSocket拼HTTP)调用下游时,SDK的自动注入可能失效。此时要手动从上下文取出trace_id、span_id与采样标志,按W3C格式拼装traceparent。示例片段如下:
ctx = OpenTelemetry::Trace::CurrentSpan.get
span_ctx = ctx || OpenTelemetry::Trace::SpanContext::INVALID
flags = span_ctx.trace_flags.sampled? ? '01' : '00'
tp = "00-#{span_ctx.trace_id.to_s(16)}-#{span_ctx.span_id.to_s(16)}-#{flags}"
req['traceparent'] = tp
这样手动写入的头和自动注入等效。重点是采样标志必须来自上游上下文,而不能在本地重新随机决定。只要下游认这个头,决策就传下去了。
Ruby服务端如何解析并延续决策
在服务端,Ruby应用要在请求进入业务代码前,从HTTP头里提取上下文。以Rack应用为例,可以在中间件里调用extract,把头里的traceparent还原成Span上下文,并设为当前上下文。后续控制器里新建的Span都会自然成为子Span,继承同样的采样标志。
class TraceContextMiddleware
def initialize(app)
@app = app
end
def call(env)
# 从Rack env的请求头中提取上下文
context = OpenTelemetry.propagation.extract(env)
OpenTelemetry::Context.with_current(context) do
@app.call(env)
end
end
end
use TraceContextMiddleware
run ->(env) {
span = OpenTelemetry.tracer_provider.tracer('srv').start_span('handle')
OpenTelemetry::Trace.with_span(span) do
[200, {'Content-Type' => 'text/plain'}, ['ok']]
end
}
上述中间件在call方法一开始就提取上下文。如果上游传来的traceparent标明不采样,那么提取出的Span上下文其采样位为未置位,之后在本服务内创建的Span也不会被导出。这样便实现了决策的向下延续。
有些Ruby Web框架(如Rails)可通过官方instrumentation自动接入该类中间件,无需手写。但理解原理有助于排查头部丢失问题:例如反向代理若清洗了traceparent,下游就会误以为是新链路并重新决策,破坏了传播。
避免服务端重新采样
一个常见错误是服务端在收到请求后,又跑了一遍本地采样器覆盖了上游决策。正确做法是:只有当提取不到上游上下文(例如请求来自系统外)时,才启用本地采样规则;一旦提取成功,就必须沿用其中的采样标志。可以用如下判断保护:
incoming_ctx = OpenTelemetry.propagation.extract(env) parent_span_ctx = OpenTelemetry::Trace.current_span(incoming_ctx).context if parent_span_ctx.valid? && parent_span_ctx.trace_flags.sampled? # 沿用上游:采样 elsif !parent_span_ctx.valid? # 无上游上下文,使用本地采样决策 end
这种写法确保外部流量可控、内部流量统一,不会出现同一个trace一部分采一部分不采的尴尬。
跨进程调用中的注意事项
当Ruby服务通过消息队列或异步任务把任务交给另一个进程时,HTTP头自然失效,但采样决策仍要带过去。此时可把上下文序列化进消息体或元数据,消费方再用对应extract逻辑还原。原理与HTTP传播一致,只是载体不同。
另外,如果链路中夹杂非Ruby服务,只要对方也遵循W3C标准,Ruby写入的traceparent就能被Java、Go等服务识别。保持头名称与格式规范,是跨语言传播采样决策的前提。不要自创字段名,否则下游读不到。
调试传播是否成功
在测试环境,可以打印出发送前的请求头与接收后的上下文,确认traceparent里的flags位一致。若发现下游采样率和上游对不上,优先检查代理是否丢头、注入是否在发送前执行、以及服务端是否误覆盖。大多数问题都出在这三处。
通过在Ruby的客户端与服务端双向落实注入与提取,网络API请求的Span采样决策就能稳定地向下游传播,使分布式追踪既省资源又完整可用。