AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

Trace、Span 与关联日志

我的学习 →

正在读取学习记录…

Trace 是一次请求从开始到结束的操作链;Span 是链中一段有起止时间的工作,例如一次模型调用或一次查政策。一次退款请求里的所有 span 共享 Trace ID,每个 span 各有 Span ID;子 span 记录父 Span ID,树形关系便能回答“这一步是谁发起的”。这些字段对应 OpenTelemetry 的 Trace 与 Span 概念。

用户问“这笔订单能退吗”,程序先让模型规划,再查退货政策。首次查询超时,第二次成功。最终回答成功,过程却确实出现过一次故障。只看最后一行“成功”会漏掉它;只搜“超时”又找不到属于哪位用户的哪次请求。

Trace ID 像一张工单号,Span ID 像工单里的步骤号。多位客服同一天都打出“查政策失败”,步骤号让日志不必在茫茫文本中认亲。根 span 没有父节点;重试是一次新的操作,应有新的 Span ID。

从树与日志看见同一次请求

固定例子有根请求、模型规划、政策首次调用、政策重试四个 span。起止偏移量直接写在程序里,表示相对请求开始的时间。ms是毫秒,1000ms等于1秒;首次查询从60ms到100ms,持续100−60=40ms,也就是0.04秒。这些时间是用来练习记录结构的固定输入。

日志是某个时刻发生的记录,span 是一段操作。日志携带当前的 Trace ID 与 Span ID,便可从“policy timeout”跳到首次查询;异常事件则直接挂在首次查询 span 上。OTel 日志数据模型定义了这些关联字段。

根请求拥有三个子 span,首次政策查询记录 ERROR,重试记录 OK;三条日志分别关联对应 span
图1:父子关系连接操作,Trace ID 与 Span ID 连接日志;失败的子操作可以由成功的重试恢复。

保存完整程序并运行

程序用 ContextVar 保存当前操作。它是Python的上下文变量,能在当前执行上下文中记住“现在属于哪一步”;进入子span时替换当前值,退出时恢复父值。@contextmanager把span函数组织成with进入、退出的成对动作,finally保证异常退出也会恢复。捕获异常的 span 标为 ERROR 并保存异常类型;根请求在重试成功后明确标为 OK。默认状态 UNSET 表示尚未明确设置,不应据此自动算成功。

在 PowerShell 创建并进入文件夹:

Bash
mkdir l7-trace
cd l7-trace

把下面完整代码保存为该文件夹的 trace_lab.py。数据已经写在程序中,直接保存即可运行。

Python
"""用固定时间输入记录一条退款请求的 span 树与关联日志。"""

from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field

ACTIVE = ContextVar("active_span", default=None)


@dataclass
class Span:
    trace_id: str
    span_id: str
    parent_span_id: str | None
    name: str
    start_ms: int
    end_ms: int
    status: str = "UNSET"
    events: list = field(default_factory=list)


class Recorder:
    def __init__(self, trace_id):
        # OTel 的 trace ID 为 32 位十六进制,span ID 为 16 位。
        if len(trace_id) != 32 or any(c not in "0123456789abcdef" for c in trace_id):
            raise ValueError("invalid trace_id")
        if int(trace_id, 16) == 0:
            raise ValueError("zero trace_id")
        self.trace_id = trace_id
        self.spans = []
        self.logs = []

    @contextmanager
    def span(self, name, start_ms, end_ms):
        # 时间来自预置数据;这里没有测量真实服务。
        if start_ms < 0 or end_ms < start_ms:
            raise ValueError("invalid time interval")
        parent = ACTIVE.get()
        if parent is not None and parent.trace_id != self.trace_id:
            raise ValueError("different trace requires explicit link")
        child = Span(self.trace_id, f"{len(self.spans) + 1:016x}",
                     parent.span_id if parent else None, name, start_ms, end_ms)
        self.spans.append(child)
        token = ACTIVE.set(child)
        try:
            yield child
        except Exception as error:
            child.status = "ERROR"
            child.events.append({"name": "exception", "type": type(error).__name__})
            raise
        finally:
            # 子操作完成后恢复父上下文,避免下一条日志串错 span。
            ACTIVE.reset(token)

    def log(self, message):
        current = ACTIVE.get()
        if current is None or current.trace_id != self.trace_id:
            raise ValueError("log requires active span")
        self.logs.append({"trace_id": self.trace_id, "span_id": current.span_id,
                          "message": message})

    def tree_lines(self):
        lines = []
        def visit(parent_id, depth):
            for span in self.spans:
                if span.parent_span_id == parent_id:
                    lines.append(f"{'  ' * depth}{span.name} id={span.span_id} "
                                 f"status={span.status} duration={span.end_ms - span.start_ms}ms")
                    visit(span.span_id, depth + 1)
        visit(None, 0)
        return lines


def build_trace():
    record = Recorder("000000000000000000000000000000a1")
    with record.span("refund-request", 0, 150) as root:
        with record.span("model-plan", 10, 60) as model:
            record.log("plan ready")
            model.status = "OK"
        try:
            with record.span("policy-attempt-1", 60, 100):
                record.log("policy timeout")
                raise TimeoutError("fixed failure")
        except TimeoutError:
            # 重试是另一个操作,拥有新的 span ID,与首次尝试同属请求。
            with record.span("policy-attempt-2", 110, 140) as retry:
                record.log("policy found")
                retry.status = "OK"
        root.status = "OK"
    return record


def main():
    record = build_trace()
    print(f"trace_id={record.trace_id}")
    print("\n".join(record.tree_lines()))
    for row in record.logs:
        print(f"log span={row['span_id']} message={row['message']}")


if __name__ == "__main__":
    main()

保存后列出文件,确认能看到 trace_lab.py,再运行:

Bash
ls
python -X utf8 trace_lab.py

程序输出:

TXT
trace_id=000000000000000000000000000000a1
refund-request id=0000000000000001 status=OK duration=150ms
  model-plan id=0000000000000002 status=OK duration=50ms
  policy-attempt-1 id=0000000000000003 status=ERROR duration=40ms
  policy-attempt-2 id=0000000000000004 status=OK duration=30ms
log span=0000000000000002 message=plan ready
log span=0000000000000003 message=policy timeout
log span=0000000000000004 message=policy found

逐项读懂结果

第一行是整条请求共同的 Trace ID。输出把它缩到尾部 a1 的图示与 32 位完整字符串对应起来;实际 trace ID 应由追踪系统生成,固定编号方便逐行核对。

接下来的四行是操作树。根 span 为 1,三个缩进的子 span 分别为 2、3、4。模型 50ms、首次政策查询 40ms、重试 30ms都由 end_ms - start_ms 算出。根的 150ms还含操作间空档,不能拿子 span 总和当作请求总时间。

policy-attempt-1 的 ERROR 来自真正抛出的 TimeoutError。异常被外层接住,重试完成,根和第二次查询为 OK。子失败不会自动把父状态改为失败;父操作是否达成业务目标需要其执行层判断。

最后三行日志的 span ID 依次为 2、3、4。源码中的每个 log() 都在相应 with record.span(...) 内,记录对象还保存共同的 Trace ID。日志文本本身不负责猜测归属。

改一处,再看对应结果

在 build_trace() 中,把重试的 record.span("policy-attempt-2", 110, 140) 改成 record.span("policy-attempt-2", 110, 145),保存后运行 python -X utf8 trace_lab.py。只看重试这一行:持续时间从 30ms 变为 35ms;它仍归属于根 span,日志仍关联 span 4。

再把末尾 root.status = "OK" 改成 root.status = "UNSET" 并重跑,根状态就会显示 UNSET。这个改动不会删除首次调用的 ERROR。完成后恢复两处原值,再运行测试。

跨线程、跨服务时怎样继续这条链

ContextVar 在当前执行上下文中恢复父子关系;把请求送到另一个服务,还需要把追踪上下文注入请求载体,并在接收方提取。跨线程也要正确传递或恢复上下文,不能把当前 span 放进一个全局可变变量让所有请求共用。OTel 上下文传播文档说明了跨进程关联与日志关联的过程。

具有多个前因的异步工作,可以用 Span Link 关联其他 span;不能为了画成一棵树随意指定不存在的父节点。一次模型生成也不宜按每个 token 建 span,通常记录一次调用,并用属性或事件补充必要信息。

关联日志同样要处理敏感内容。记录错误类别、工具名、必要的脱敏业务标识通常就够排查;原始客户文本、密钥和完整工具响应应由独立采集策略控制。Trace ID 用来找链路,不承担访问授权。

保存并运行测试

7 项测试覆盖共同 Trace ID、父子关系、重试独立编号、异常事件、日志归属,以及正常和异常退出后的上下文恢复。

把下面代码保存为同一文件夹的 test_trace_lab.py,确认两个文件都在当前目录,运行:

Bash
ls
python -X utf8 -m unittest -v test_trace_lab.py
完整测试:test_trace_lab.py
Python
"""检查父子关系、异常事件与日志关联。"""

import sys
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import trace_lab as lab


class TraceTests(unittest.TestCase):
    def test_parent_and_trace_ids(self):
        record = lab.build_trace()
        root, *children = record.spans
        self.assertIsNone(root.parent_span_id)
        self.assertEqual({s.parent_span_id for s in children}, {root.span_id})
        self.assertEqual({s.trace_id for s in record.spans}, {record.trace_id})

    def test_attempts_have_distinct_ids(self):
        record = lab.build_trace()
        self.assertEqual(len({s.span_id for s in record.spans}), 4)
        self.assertEqual([s.status for s in record.spans], ["OK", "OK", "ERROR", "OK"])

    def test_error_event_is_attached_to_failed_span(self):
        self.assertEqual(lab.build_trace().spans[2].events,
                         [{"name": "exception", "type": "TimeoutError"}])

    def test_logs_follow_current_span(self):
        record = lab.build_trace()
        self.assertEqual([r["span_id"] for r in record.logs],
                         [s.span_id for s in record.spans[1:]])
        self.assertTrue(all(r["trace_id"] == record.trace_id for r in record.logs))

    def test_context_restores_after_exception(self):
        lab.build_trace()
        self.assertIsNone(lab.ACTIVE.get())
        with self.assertRaises(ValueError):
            lab.Recorder("0" * 31 + "1").log("outside")

    def test_nested_operation_restores_parent(self):
        record = lab.Recorder("0" * 31 + "1")
        with record.span("parent", 0, 20) as parent:
            with record.span("child", 1, 10):
                pass
            record.log("parent resumed")
        self.assertEqual(record.logs[0]["span_id"], parent.span_id)

    def test_invalid_ids_and_intervals(self):
        for value in ("0" * 32, "x" * 32, "a"):
            with self.assertRaises(ValueError):
                lab.Recorder(value)
        with self.assertRaises(ValueError):
            with lab.Recorder("0" * 31 + "1").span("bad", 3, 2):
                pass


if __name__ == "__main__":
    unittest.main()

继续查规范

先读 OTel Traces 的 Spans 与 Span Context,对照名称、时间、父 ID、状态、事件与 Link。再读 日志数据模型的 TraceId/SpanId,把“操作持续多久”和“哪条日志属于它”分开核对。

课后练习展开习题收起习题

如果重试失败后转人工,根 span 的业务结果应怎么记录?如果两个模型调用并行,为什么不能依靠日志打印顺序恢复父子关系?

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.1.0

选择题 · 一次订单查询和生成答复怎样关联到同一任务

一次订单查询和生成答复怎样关联到同一任务?

对应知识点:L7-trace

选择一个答案
练习 2 / 4 · v1.1.0

简答题 · 解释机制

Trace、Span和关联日志分别表示什么?

对应知识点:L7-trace

练习 3 / 4 · v1.1.0

代码题 · 核对给定数据

写一条断言检查查询Span与任务根Span具有相同trace_id。

对应知识点:L7-trace

练习 4 / 4 · v1.1.0

场景题 · 分析具体变化

工具查询返回错误时,需要保留哪些字段定位?

对应知识点:L7-trace

图片查看器
100%

文章配图 · 按 Esc 关闭