games-development-ai/cheap-worker/tests/test_trace_propagation.py
lili 5b34807e73
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap): 面四 Python 三跳 traceparent 打通——便宜档生成串成单条 trace 树(观测阶段四)
Java agent(面一b)已在派发出网自动注 traceparent;本片补 Python 侧三个断点,让一次便宜档生成
在 Tempo 里是 Java→worker(:9501)→cheap-service(:8300)生成 的单条 trace 树而非散落 root:
- 断点①:worker_service.do_POST 抓入站 traceparent 进 job._otelCarrier(self.headers 仅此可得);
  process_job 用 worker_span_scope 在入站 context 下建 worker SERVER span、包住真生成调用(span 建在
  真处理 job 的 worker 线程,非只入队即 202 的 do_POST)。
- 断点②:driver 读当前 context(worker span 经 asyncio.run 透传进协程)inject 成 W3C carrier,随出站
  :8300 各 POST header 带上;同份 carrier 写会话 sidecar。
- 断点③:/chat reply 在 AgentScope 后台任务跑、与 HTTP 请求解耦读不到实时 context——故走既有 C2 sidecar
  桥:cheap_service_app 工厂读 sidecar traceparent → parent_carrier → CheapOtlpSink 每步生成 span 挂入站
  context 当子 span(不再私有 root)。

传播 helper 集中 cheap_otlp_sink(extract/inject/worker_span_scope);全程 best-effort(otel 不可用/carrier
坏/Collector 不可达都退化成少串一段 trace、生成异常原样穿出)、CHEAP_OTLP_ENDPOINT 默认关时零行为变化
(parent=None 退私有 root、sidecar 不写 traceparent 字节不变)、代理旁路红线未破、业务 traceId 并存(§10 决策5)。

test_trace_propagation.py 14 例(含端到端三跳单条 trace 树,InMemorySpanExporter 证父子链+同 trace_id)
+ test_worker_service +2;主控亲跑 40 passed(3 文件)+ 全量 364 passed 1 skipped 无回退。
真跨进程 Tempo trace 树验证 = dev 开 CHEAP_OTLP_ENDPOINT 重跑 follow-up。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:30:04 -07:00

215 lines
12 KiB
Python

"""test_trace_propagation.py — 便宜档面四三跳 W3C traceparent 传播单测(阶段四观测)。
守的不变量(把一次便宜档生成串成 Java→worker→cheap-service 单条 trace 树的三个断点):
· 断点①提取:propagator 从构造的 traceparent header 提取出正确的 trace_id/span_id(is_remote)。
· 断点①建 span:OTLP 开时 worker_span_scope 在入站 context 下开 cheap.worker.generate SERVER span 当子,
业务 traceId 并存(§10 决策5)作属性;OTLP 关时只透传入站 context(不依赖开关)。
· 断点②注入:worker_span_scope 内 current_traceparent_carrier() 注入出的 traceparent 与入站同 trace_id(往返一致)。
· 断点③挂靠:CheapOtlpSink(parent_carrier=...) 让生成 span 挂到入站 context 下当子 span(不再私有 root);
缺省 parent_carrier=None 时保持波③ 私有 root 行为(默认关字节不变)。
· best-effort(设计 §8):坏 carrier 不抛;生成异常原样穿出 worker_span_scope(worker loop 据此发兜底 failed)。
只本地跑、零网络:用 InMemorySpanExporter / 手工 traceparent 构造断言,不发真 Collector。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_trace_propagation.py -q
"""
import os
import sys
from pathlib import Path
# sys.path:cheap-worker/ + tier2/gen-worker/(observability / service / worker 包;与 test_cheap_otlp_sink 同范式)。
_HERE = os.path.dirname(os.path.abspath(__file__))
_CW = os.path.dirname(_HERE)
_REPO_ROOT = os.path.dirname(_CW)
_GEN_WORKER = os.path.join(_REPO_ROOT, "tier2", "gen-worker")
for _p in (_CW, _GEN_WORKER):
if _p not in sys.path:
sys.path.insert(0, _p)
import pytest # noqa: E402
import cheap_otlp_sink as S # noqa: E402
from opentelemetry import trace as _otel_trace # noqa: E402
from opentelemetry.trace import SpanKind # noqa: E402
from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402
InMemorySpanExporter,
)
# 固定构造的入站 W3C 值(模拟 Java OTel agent 出网注入的 traceparent)。
_TRACE_HEX = "11112222333344445555666677778888"
_SPAN_HEX = "1111222233334444"
_TP = f"00-{_TRACE_HEX}-{_SPAN_HEX}-01"
def _span_ctx(ctx):
"""从 extract 出的 Context 取当前 span 的 SpanContext。"""
return _otel_trace.get_current_span(ctx).get_span_context()
# ── 断点①提取:propagator 从 traceparent header 提取正确 trace_id/span_id ─────────────
def test_extract_from_traceparent_header_yields_correct_ids():
ctx = S._extract_remote_context({"traceparent": _TP})
assert ctx is not None
sc = _span_ctx(ctx)
assert format(sc.trace_id, "032x") == _TRACE_HEX
assert format(sc.span_id, "016x") == _SPAN_HEX
assert sc.is_remote is True # 入站(远端)context
def test_extract_none_or_missing_traceparent_returns_none():
assert S._extract_remote_context(None) is None
assert S._extract_remote_context({}) is None
assert S._extract_remote_context({"tracestate": "x=1"}) is None # 无 traceparent 键
# ── 断点②注入:worker_span_scope(OTLP 关)内注入的 traceparent 与入站同 trace_id(往返一致)──
def test_inject_roundtrip_trace_id_consistent_when_otlp_off(monkeypatch):
monkeypatch.setattr(S, "resolve_otlp_endpoint", lambda explicit=None: None) # 强制 OTLP 关
S.reset_shared_tracer_for_test()
with S.worker_span_scope({"traceparent": _TP}, conversation_id="w1", business_trace_id="tp1"):
out = S.current_traceparent_carrier()
assert "traceparent" in out
parts = out["traceparent"].split("-")
assert parts[1] == _TRACE_HEX, "出站 trace_id 必与入站一致(往返一致)"
# OTLP 关:无 worker span,当前 = 入站 remote context → span_id 段透传入站 Java span(直挂 Java 下)。
assert parts[2] == _SPAN_HEX
def test_current_carrier_empty_when_no_context():
"""无任何当前 context → 注入空 carrier(出站少带 header,不抛)。"""
S.reset_shared_tracer_for_test()
out = S.current_traceparent_carrier()
assert out == {} or "traceparent" not in out
# ── 断点①建 span:OTLP 开 → worker span 在入站 context 下当子 + 业务 traceId 并存 ────────
def test_worker_span_scope_creates_child_server_span_when_otlp_on(monkeypatch):
S.reset_shared_tracer_for_test()
mem = InMemorySpanExporter()
monkeypatch.setattr(S, "resolve_otlp_endpoint", lambda explicit=None: "http://collector.test:4318")
# 预置进程级共享 tracer 用内存 exporter(worker_span_scope 复用同一单例,无需真网络)。
S._get_shared_tracer("http://collector.test:4318", span_exporter=mem)
with S.worker_span_scope({"traceparent": _TP}, conversation_id="w1", business_trace_id="tp1"):
inj = S.current_traceparent_carrier() # 作用域内当前 = worker span
spans = mem.get_finished_spans()
assert len(spans) == 1
ws = spans[0]
assert ws.name == "cheap.worker.generate"
assert ws.kind == SpanKind.SERVER
# worker span 在入站 trace 下(同 trace_id),父 = 入站 Java span(不新造 root)。
assert format(ws.context.trace_id, "032x") == _TRACE_HEX
assert ws.parent is not None and format(ws.parent.span_id, "016x") == _SPAN_HEX
# 断点②:作用域内注入的 traceparent = worker span 自身(同 trace,span_id = worker span)。
p = inj["traceparent"].split("-")
assert p[1] == _TRACE_HEX and p[2] == format(ws.context.span_id, "016x")
# §10 决策5 业务 traceId 并存:gameId 作 conversation.id、业务 traceId 另存属性(都不改格式)。
attrs = dict(ws.attributes)
assert attrs.get("gen_ai.conversation.id") == "w1"
assert attrs.get("agentscope.cheap.business_trace_id") == "tp1"
S.reset_shared_tracer_for_test()
# ── 断点③挂靠:CheapOtlpSink(parent_carrier) 让生成 span 挂到入站 context 下当子 span ────
def test_cheap_service_gen_span_parents_under_inbound_carrier():
S.reset_shared_tracer_for_test()
mem = InMemorySpanExporter()
sink = S.make_cheap_otlp_sink(trace_id="conv-x", endpoint="http://collector.test:4318",
span_exporter=mem, parent_carrier={"traceparent": _TP})
sink({"traceId": "conv-x", "step": 0, "cost": None, "verdict": None,
"timestamp": "2026-07-05T10:00:00", "ext": {"raw": {"event": "ReplyStartEvent"}}})
spans = mem.get_finished_spans()
assert len(spans) == 1
sp = spans[0]
# 生成 span 挂在入站 trace 下(同 trace_id、父 = 入站 span);不再私有 root。
assert format(sp.context.trace_id, "032x") == _TRACE_HEX
assert sp.parent is not None and format(sp.parent.span_id, "016x") == _SPAN_HEX
# 业务 traceId 并存:conversation.id 仍是业务 traceId(承担产物归位/回调关联),不被 W3C 贯穿覆盖。
assert dict(sp.attributes).get("gen_ai.conversation.id") == "conv-x"
S.reset_shared_tracer_for_test()
def test_cheap_service_gen_span_is_root_when_no_parent_carrier():
"""缺省 parent_carrier=None(未开传播)→ 生成 span 仍私有 root(波③ 行为字节不变)。"""
S.reset_shared_tracer_for_test()
mem = InMemorySpanExporter()
sink = S.make_cheap_otlp_sink(trace_id="conv-y", endpoint="http://collector.test:4318",
span_exporter=mem) # parent_carrier 缺省 None
sink({"traceId": "conv-y", "step": 0, "ext": {"raw": {"event": "ReplyStartEvent"}}})
sp = mem.get_finished_spans()[0]
assert sp.parent is None, "未开传播时保持私有 root(默认关字节不变)"
S.reset_shared_tracer_for_test()
def test_build_trace_sink_threads_parent_carrier_to_child_span():
"""build_trace_sink(parent_carrier=...) 开时:fan-out 的 OTLP 侧生成 span 也挂到入站 context 下当子。"""
S.reset_shared_tracer_for_test()
mem = InMemorySpanExporter()
seen = []
fan = S.build_trace_sink(lambda step: seen.append(step), trace_id="g-9",
endpoint="http://collector.test:4318", span_exporter=mem,
parent_carrier={"traceparent": _TP})
fan({"traceId": "g-9", "step": 0, "ext": {"raw": {"event": "ReplyStartEvent"}}})
assert len(seen) == 1 # jsonl 侧照落不被吞
sp = mem.get_finished_spans()[0]
assert format(sp.context.trace_id, "032x") == _TRACE_HEX # OTLP 侧挂到入站 trace 下
assert sp.parent is not None and format(sp.parent.span_id, "016x") == _SPAN_HEX
S.reset_shared_tracer_for_test()
# ── 三跳串成单条 trace 树(端到端:入站 Java → worker span → cheap-service 生成 span)──────
def test_three_hops_form_single_trace_tree(monkeypatch):
"""把三个断点串起来:worker span 挂在入站 Java span 下,生成 span 又挂在 worker span 下 —— 同一 trace_id、
父子链完整(正是 dev 真机在 Tempo 里要看到的 Java→worker→cheap-service 单条 trace 树)。"""
S.reset_shared_tracer_for_test()
mem = InMemorySpanExporter()
monkeypatch.setattr(S, "resolve_otlp_endpoint", lambda explicit=None: "http://collector.test:4318")
S._get_shared_tracer("http://collector.test:4318", span_exporter=mem)
# 跳①:worker 线程在入站 Java context 下开 worker span,取其出站 carrier(= driver 会注入 header/写 sidecar 的)。
with S.worker_span_scope({"traceparent": _TP}, conversation_id="w1", business_trace_id="tp1"):
worker_carrier = S.current_traceparent_carrier()
# 跳③:cheap-service 端拿 worker 的 carrier 当 parent(经 sidecar 桥),生成 span 挂到 worker span 下。
gen_sink = S.make_cheap_otlp_sink(trace_id="w1", endpoint="http://collector.test:4318",
span_exporter=mem, parent_carrier=worker_carrier)
gen_sink({"traceId": "w1", "step": 0, "ext": {"raw": {"event": "ReplyStartEvent"}}})
spans = {sp.name: sp for sp in mem.get_finished_spans()}
worker_sp = spans["cheap.worker.generate"]
gen_sp = spans["step 0 · ReplyStartEvent"]
# 三段同一 trace_id(单条 trace)。
assert format(worker_sp.context.trace_id, "032x") == _TRACE_HEX
assert format(gen_sp.context.trace_id, "032x") == _TRACE_HEX
# 父子链:worker span 父 = 入站 Java span;生成 span 父 = worker span。
assert format(worker_sp.parent.span_id, "016x") == _SPAN_HEX
assert gen_sp.parent.span_id == worker_sp.context.span_id
S.reset_shared_tracer_for_test()
# ── best-effort 铁律(设计 §8):坏 carrier 不抛;生成异常原样穿出 ────────────────────────
def test_worker_span_scope_bad_carrier_does_not_raise(monkeypatch):
monkeypatch.setattr(S, "resolve_otlp_endpoint", lambda explicit=None: None)
with S.worker_span_scope({"traceparent": "garbage-not-w3c"}, conversation_id="w"):
pass # 坏 traceparent 只导致无父,不抛
def test_worker_span_scope_does_not_swallow_body_exception(monkeypatch):
"""生成异常必须原样穿出 worker_span_scope —— worker loop 据此发兜底 failed 回调,绝不被观测吞掉。"""
monkeypatch.setattr(S, "resolve_otlp_endpoint", lambda explicit=None: None)
with pytest.raises(ValueError):
with S.worker_span_scope({"traceparent": _TP}):
raise ValueError("boom")
def test_worker_span_scope_body_exception_ends_span_when_otlp_on(monkeypatch):
"""OTLP 开时生成异常:worker span 仍被 end(不漏 span)、异常照常穿出。"""
S.reset_shared_tracer_for_test()
mem = InMemorySpanExporter()
monkeypatch.setattr(S, "resolve_otlp_endpoint", lambda explicit=None: "http://collector.test:4318")
S._get_shared_tracer("http://collector.test:4318", span_exporter=mem)
with pytest.raises(RuntimeError):
with S.worker_span_scope({"traceparent": _TP}, conversation_id="w1"):
raise RuntimeError("gen crashed")
spans = mem.get_finished_spans()
assert len(spans) == 1 and spans[0].name == "cheap.worker.generate" # 异常路 span 照样收尾导出
S.reset_shared_tracer_for_test()