分布式链路追踪利用共同的链路标识和明确的关系,连接一次请求中被记录下来的操作。一个跨度表示一项操作,带有开始与结束时间、属性、事件和状态。当结账请求依次涉及应用、库存服务和支付服务时,这些信息可以帮助我们还原处理路径。但最终图形取决于哪些位置做了插桩、哪些数据通过采样并成功导出,不能把它当成请求实际执行过的全部动作清单。

链路标识用于归集相关跨度,跨度标识用于区分同一条链路中的具体操作,父跨度标识则连接直接的上游操作。通过 HTTP 跨越边界时,W3C 的 traceparent 请求头携带链路标识、发送端跨度标识和链路标志。接收端必须提取这些上下文,再创建自己的跨度。仅仅在几条互不关联的日志里写入同一个请求编号,并不会自动形成可供追踪系统识别的跨度父子关系。对于由多个上游共同触发的工作,可以用跨度链接表达额外关系,而不是给一个跨度分配多个父级。

原文本地 HTTP 样例中,链路 A 由 request 根跨度、其子跨度 outbound,以及具有远端父上下文的 handle/ok 组成。第二个请求不带传播头,创建另一条链路的 handle/fail 根跨度。HTTP 503、异常事件与显式 ERROR 状态是分别记录的信息。跨度缺失不能证明操作未运行。样例只使用同一进程中的两个提供器,不验证采集器可靠性或时钟同步。
链路 A 与 B 是原文两组链路的图解名称,不是真实标识。图解仅依据原文叙述与未执行代码;历史状态仍为已阅读、执行受阻,本次没有重跑或新增运行验证。箭头长度不表示耗时。

先读关系,再分析耗时

一个服务端跨度可能包含数据库操作,也可能包含多个同时进行的外部请求。把所有跨度的时长相加,会重复计算重叠的时间;如果兄弟跨度并发执行,用父跨度时长减去所有子跨度时长,也可能得到毫无意义的负数。应当阅读时间线,判断哪些区间位于影响请求完成的关键路径。跨机器的时钟差异以及缺失的插桩,还会限制我们对图上空白区间的解释精度。

跨度状态也需要单独理解。HTTP 响应码、异常事件和 OpenTelemetry 的错误状态是三份不同的信息,插桩库可能依据自己的约定将它们关联起来。手工记录异常时,不能因此假定所有有关的状态字段都已经改变。服务名称等资源属性回答数据由哪个服务发出,而某个跨度的名称、属性和事件回答该服务中发生了什么操作。混淆这两个层次,会使跨服务排查失去可靠的定位依据。

坏掉的样子

代理可能丢弃传播请求头,接收服务也可能没有调用上下文提取逻辑。此时业务请求已经成功抵达下游,但下游工作在追踪系统里属于另一条链路。还有一种错误是链路标识相同,父跨度却连接错误:数据看似聚到一起,实际因果位置已经不对。验证一个服务边界时,既要检查链路标识,也要检查直接父跨度,不能只确认后端收到了几条跨度就宣布传播正确。

找不到某个跨度,可能是采样舍弃、尚未插桩、异步执行中丢失上下文,或者导出失败造成的,不能据此证明该操作没有运行。同样,界面里一片绿色,也不代表所有下游依赖都成功了。需要结合请求最终结果指标和相关日志,核对采集器及导出器的健康状况,然后再解释链路图。不完整的数据可以帮助提出问题,却不能独自证明一条没有观测缺口的完整处理路径。

快速验证

先创建 Python 虚拟环境,安装 opentelemetry-sdk==1.35.0,其依赖会安装匹配的 API 版本。将以下代码保存为 trace-flow.py,再使用该环境的 Python 执行;原文报告曾使用 Python 3.14.4 执行该练习。本次修订保留原练习但未重新运行,当前执行检查仍受阻。脚本启动仅监听本机回环地址的 HTTP 服务,分别为调用者和接收者创建追踪提供器,把完成的跨度写入内存导出器。请求结束后会关闭服务,无须准备独立的遥测采集器。获得授权后运行之前,还应确认代理配置绕过 127.0.0.1,否则 urllib 可能使用已配置的代理。建立本地监听和发起 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 通信。完整运行通过时,验证范围也只包括上下文传播与跨度记录,不涵盖跨机器时钟同步、采集器可靠性或并发路径的关键耗时。部署到实际服务后,应当让同类边界检查经过真实入口代理和客户端库,并检查采样配置与导出健康度。只有先弄清哪些关系得到记录、哪些环节仍可能缺失,才适合用这张时间线解释一次故障。

来源