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 日志数据模型定义了这些关联字段。
保存完整程序并运行
程序用 ContextVar 保存当前操作。它是Python的上下文变量,能在当前执行上下文中记住“现在属于哪一步”;进入子span时替换当前值,退出时恢复父值。@contextmanager把span函数组织成with进入、退出的成对动作,finally保证异常退出也会恢复。捕获异常的 span 标为 ERROR 并保存异常类型;根请求在重试成功后明确标为 OK。默认状态 UNSET 表示尚未明确设置,不应据此自动算成功。
在 PowerShell 创建并进入文件夹:
mkdir l7-trace
cd l7-trace把下面完整代码保存为该文件夹的 trace_lab.py。数据已经写在程序中,直接保存即可运行。
"""用固定时间输入记录一条退款请求的 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,再运行:
ls
python -X utf8 trace_lab.py程序输出:
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,确认两个文件都在当前目录,运行:
ls
python -X utf8 -m unittest -v test_trace_lab.py完整测试:test_trace_lab.py
"""检查父子关系、异常事件与日志关联。"""
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 的业务结果应怎么记录?如果两个模型调用并行,为什么不能依靠日志打印顺序恢复父子关系?
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 一次订单查询和生成答复怎样关联到同一任务
一次订单查询和生成答复怎样关联到同一任务?
对应知识点:L7-trace
简答题 · 解释机制
Trace、Span和关联日志分别表示什么?
对应知识点:L7-trace
代码题 · 核对给定数据
写一条断言检查查询Span与任务根Span具有相同trace_id。
对应知识点:L7-trace
场景题 · 分析具体变化
工具查询返回错误时,需要保留哪些字段定位?
对应知识点:L7-trace
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。