分布式链路追踪利用共同的链路标识和明确的关系,连接一次请求中被记录下来的操作。一个跨度表示一项操作,带有开始与结束时间、属性、事件和状态。当结账请求依次涉及应用、库存服务和支付服务时,这些信息可以帮助我们还原处理路径。但最终图形取决于哪些位置做了插桩、哪些数据通过采样并成功导出,不能把它当成请求实际执行过的全部动作清单。
链路标识用于归集相关跨度,跨度标识用于区分同一条链路中的具体操作,父跨度标识则连接直接的上游操作。通过 HTTP 跨越边界时,W3C 的 traceparent 请求头携带链路标识、发送端跨度标识和链路标志。接收端必须提取这些上下文,再创建自己的跨度。仅仅在几条互不关联的日志里写入同一个请求编号,并不会自动形成可供追踪系统识别的跨度父子关系。对于由多个上游共同触发的工作,可以用跨度链接表达额外关系,而不是给一个跨度分配多个父级。
先读关系,再分析耗时
一个服务端跨度可能包含数据库操作,也可能包含多个同时进行的外部请求。把所有跨度的时长相加,会重复计算重叠的时间;如果兄弟跨度并发执行,用父跨度时长减去所有子跨度时长,也可能得到毫无意义的负数。应当阅读时间线,判断哪些区间位于影响请求完成的关键路径。跨机器的时钟差异以及缺失的插桩,还会限制我们对图上空白区间的解释精度。
跨度状态也需要单独理解。HTTP 响应码、异常事件和 OpenTelemetry 的错误状态是三份不同的信息,插桩库可能依据自己的约定将它们关联起来。手工记录异常时,不能因此假定所有有关的状态字段都已经改变。服务名称等资源属性回答数据由哪个服务发出,而某个跨度的名称、属性和事件回答该服务中发生了什么操作。混淆这两个层次,会使跨服务排查失去可靠的定位依据。
坏掉的样子
代理可能丢弃传播请求头,接收服务也可能没有调用上下文提取逻辑。此时业务请求已经成功抵达下游,但下游工作在追踪系统里属于另一条链路。还有一种错误是链路标识相同,父跨度却连接错误:数据看似聚到一起,实际因果位置已经不对。验证一个服务边界时,既要检查链路标识,也要检查直接父跨度,不能只确认后端收到了几条跨度就宣布传播正确。
找不到某个跨度,可能是采样舍弃、尚未插桩、异步执行中丢失上下文,或者导出失败造成的,不能据此证明该操作没有运行。同样,界面里一片绿色,也不代表所有下游依赖都成功了。需要结合请求最终结果指标和相关日志,核对采集器及导出器的健康状况,然后再解释链路图。不完整的数据可以帮助提出问题,却不能独自证明一条没有观测缺口的完整处理路径。
快速验证
先创建 Python 虚拟环境,安装 opentelemetry-sdk==1.35.0,其依赖会安装匹配的 API 版本。将以下代码保存为 trace-flow.py,再使用该环境的 Python 执行;本次实际使用 Python 3.14.4。脚本启动仅监听本机回环地址的 HTTP 服务,分别为调用者和接收者创建追踪提供器,把完成的跨度写入内存导出器。请求结束后会关闭服务,无须准备独立的遥测采集器。
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from opentelemetry.context import Context
from opentelemetry.sdk.resources import Resource
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.trace import SpanKind, Status, StatusCode
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
exporter = InMemorySpanExporter()
providers = [TracerProvider(resource=Resource({"service.name": name}))
for name in ("caller", "worker")]
for provider in providers:
provider.add_span_processor(SimpleSpanProcessor(exporter))
caller, worker = [p.get_tracer("trace-lab") for p in providers]
propagator = TraceContextTextMapPropagator()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
carrier = {key.lower(): value for key, value in self.headers.items()}
parent = propagator.extract(carrier, context=Context())
with worker.start_as_current_span("handle" + self.path,
context=parent, kind=SpanKind.SERVER) as span:
status = 200
if self.path == "/fail":
try:
raise ValueError("synthetic inventory failure")
except ValueError as error:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR, str(error)))
status = 503
body = b"lab response\n"
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{server.server_port}"
try:
with caller.start_as_current_span("request", context=Context()):
with caller.start_as_current_span("outbound", kind=SpanKind.CLIENT):
headers = {}
propagator.inject(headers)
with urlopen(Request(base + "/ok", headers=headers), timeout=5) as response:
assert response.status == 200
response.read()
try:
with urlopen(base + "/fail", timeout=5) as response:
raise AssertionError("Expected HTTP 503")
except HTTPError as response:
with response:
assert response.code == 503
response.read()
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
for provider in providers:
assert provider.force_flush()
spans = {span.name: span for span in exporter.get_finished_spans()}
assert len(spans) == 4
root, client, received, orphan = [spans[name]
for name in ("request", "outbound", "handle/ok", "handle/fail")]
assert root.parent is None
assert root.context.trace_id == client.context.trace_id == received.context.trace_id
assert client.parent.span_id == root.context.span_id
assert received.parent.span_id == client.context.span_id and received.parent.is_remote
assert orphan.parent is None and orphan.context.trace_id != root.context.trace_id
assert orphan.status.status_code == StatusCode.ERROR
assert [event.name for event in orphan.events] == ["exception"]
assert received.resource.attributes["service.name"] == "worker"
assert client.resource.attributes["service.name"] == "caller"
print(json.dumps({"exported_spans": len(spans), "linked_spans": 3,
"unpropagated_request_started_new_trace": True,
"failed_span_status": orphan.status.status_code.name,
"exception_events": len(orphan.events)}, indent=2))
for provider in providers:
provider.shutdown()
第一个请求应形成 request、outbound 和 handle/ok 三个相连跨度。断言逐项核对共同的链路标识、两处正确的父子连接、服务端识别出的远端父上下文,以及两个提供器不同的服务名称。第二个请求故意不携带传播头,服务返回五百零三响应,并创建另一条链路的根跨度;代码明确记录一个异常事件,同时设置错误状态。最终总共导出四个跨度,这些结果分别证明连接正常、边界断开和失败信息记录的实际行为。
这仍然是同一个 Python 进程中的两个提供器,经由真实本地 HTTP 通信。它验证的是上下文传播与跨度记录,没有测量跨机器时钟同步、采集器可靠性或并发路径的关键耗时。部署到实际服务后,应当让同类边界检查经过真实入口代理和客户端库,并检查采样配置与导出健康度。只有先弄清哪些关系得到记录、哪些环节仍可能缺失,才适合用这张时间线解释一次故障。
来源
- OpenTelemetry traces,跨度结构、父子关系与链路标识,已核验 2026-09-13。
- W3C Trace Context,traceparent 字段及跨进程传播约定,已核验 2026-09-13。
- OpenTelemetry Python instrumentation,手工创建跨度、处理器与显式记录异常,已核验 2026-09-13。