From a903c61086a1640491b650902703b806bf97fa1b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 21:41:43 +0800 Subject: [PATCH 1/2] Add W3C Trace Context propagation The observability tracer and agent_trace spans carried no IDs, so a span on one side of an HTTP call could not be correlated with the work it triggered downstream. Add the W3C Trace Context standard: generate, parse, and propagate traceparent / tracestate headers with an injectable RNG for deterministic IDs under test. Wired through facade, executor (AC_trace_inject / AC_trace_extract), MCP, and the Script Builder with a headless test batch and EN/Zh docs. --- README.md | 7 + README/README_zh-CN.md | 7 + README/README_zh-TW.md | 7 + .../Eng/doc/new_features/v76_features_doc.rst | 51 ++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v76_features_doc.rst | 43 +++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 10 ++ .../gui/script_builder/command_schema.py | 18 +++ .../utils/executor/action_executor.py | 31 ++++ .../utils/mcp_server/tools/_factories.py | 27 ++++ .../utils/mcp_server/tools/_handlers.py | 10 ++ .../utils/trace_context/__init__.py | 13 ++ .../utils/trace_context/trace_context.py | 149 ++++++++++++++++++ .../headless/test_trace_context_batch.py | 112 +++++++++++++ 15 files changed, 487 insertions(+) create mode 100644 docs/source/Eng/doc/new_features/v76_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v76_features_doc.rst create mode 100644 je_auto_control/utils/trace_context/__init__.py create mode 100644 je_auto_control/utils/trace_context/trace_context.py create mode 100644 test/unit_test/headless/test_trace_context_batch.py diff --git a/README.md b/README.md index 405eda3c..a1d64203 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — W3C Trace Context Propagation](#whats-new-2026-06-21--w3c-trace-context-propagation) - [What's new (2026-06-21) — HTTP Record & Replay Cassette](#whats-new-2026-06-21--http-record--replay-cassette) - [What's new (2026-06-21) — Bulkhead & Rate-Limit Headers](#whats-new-2026-06-21--bulkhead--rate-limit-headers) - [What's new (2026-06-21) — Streaming Latency Percentiles](#whats-new-2026-06-21--streaming-latency-percentiles) @@ -128,6 +129,12 @@ --- +## What's new (2026-06-21) — W3C Trace Context Propagation + +Correlate spans and logs across HTTP boundaries. Full reference: [`docs/source/Eng/doc/new_features/v76_features_doc.rst`](docs/source/Eng/doc/new_features/v76_features_doc.rst). + +- **`SpanContext` / `new_root_context` / `child_context` / `inject_context` / `extract_context`** (`AC_trace_inject`, `AC_trace_extract`): the existing tracer and `agent_trace` spans carried no IDs, so a span on one side of an HTTP call couldn't be correlated with the work it triggered on the other. This implements the W3C Trace Context standard — generate/parse/propagate `traceparent` + `tracestate` headers (version-`00`, rejects malformed/all-zero IDs), with an injectable RNG for deterministic IDs in tests. Pure-stdlib. + ## What's new (2026-06-21) — HTTP Record & Replay Cassette Re-run API flows in CI with no live server. Full reference: [`docs/source/Eng/doc/new_features/v75_features_doc.rst`](docs/source/Eng/doc/new_features/v75_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 0fa003b0..934ee518 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — W3C Trace Context 传播](#本次更新-2026-06-21--w3c-trace-context-传播) - [本次更新 (2026-06-21) — HTTP 录制与重播卡带](#本次更新-2026-06-21--http-录制与重播卡带) - [本次更新 (2026-06-21) — 隔舱与速率限制标头](#本次更新-2026-06-21--隔舱与速率限制标头) - [本次更新 (2026-06-21) — 流式延迟百分位](#本次更新-2026-06-21--流式延迟百分位) @@ -127,6 +128,12 @@ --- +## 本次更新 (2026-06-21) — W3C Trace Context 传播 + +跨 HTTP 边界关联 span 与日志。完整参考:[`docs/source/Zh/doc/new_features/v76_features_doc.rst`](../docs/source/Zh/doc/new_features/v76_features_doc.rst)。 + +- **`SpanContext` / `new_root_context` / `child_context` / `inject_context` / `extract_context`**(`AC_trace_inject`、`AC_trace_extract`):既有追踪器与 `agent_trace` 的 span 不带 ID,因此一次 HTTP 调用一端的 span 无法与它在另一端触发的工作关联。本功能实现 W3C Trace Context 标准 —— 生成/解析/传播 `traceparent` + `tracestate` 标头(version-`00`,拒绝格式不符/全零 ID),并以可注入 RNG 让测试中的 ID 确定。纯标准库。 + ## 本次更新 (2026-06-21) — HTTP 录制与重播卡带 在 CI 中重跑 API 流程,无需在线服务器。完整参考:[`docs/source/Zh/doc/new_features/v75_features_doc.rst`](../docs/source/Zh/doc/new_features/v75_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 50e2f4cf..2881a102 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — W3C Trace Context 傳播](#本次更新-2026-06-21--w3c-trace-context-傳播) - [本次更新 (2026-06-21) — HTTP 錄製與重播卡帶](#本次更新-2026-06-21--http-錄製與重播卡帶) - [本次更新 (2026-06-21) — 隔艙與速率限制標頭](#本次更新-2026-06-21--隔艙與速率限制標頭) - [本次更新 (2026-06-21) — 串流延遲百分位](#本次更新-2026-06-21--串流延遲百分位) @@ -127,6 +128,12 @@ --- +## 本次更新 (2026-06-21) — W3C Trace Context 傳播 + +跨 HTTP 邊界關聯 span 與日誌。完整參考:[`docs/source/Zh/doc/new_features/v76_features_doc.rst`](../docs/source/Zh/doc/new_features/v76_features_doc.rst)。 + +- **`SpanContext` / `new_root_context` / `child_context` / `inject_context` / `extract_context`**(`AC_trace_inject`、`AC_trace_extract`):既有追蹤器與 `agent_trace` 的 span 不帶 ID,因此一次 HTTP 呼叫一端的 span 無法與它在另一端觸發的工作關聯。本功能實作 W3C Trace Context 標準 —— 產生/解析/傳播 `traceparent` + `tracestate` 標頭(version-`00`,拒絕格式不符/全零 ID),並以可注入 RNG 讓測試中的 ID 具決定性。純標準函式庫。 + ## 本次更新 (2026-06-21) — HTTP 錄製與重播卡帶 在 CI 中重跑 API 流程,無需線上伺服器。完整參考:[`docs/source/Zh/doc/new_features/v75_features_doc.rst`](../docs/source/Zh/doc/new_features/v75_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v76_features_doc.rst b/docs/source/Eng/doc/new_features/v76_features_doc.rst new file mode 100644 index 00000000..84a2eb7d --- /dev/null +++ b/docs/source/Eng/doc/new_features/v76_features_doc.rst @@ -0,0 +1,51 @@ +W3C Trace Context Propagation +============================= + +The ``observability`` tracer and ``agent_trace`` spans carry no IDs, so a span +on one side of an HTTP call could not be correlated with the work it triggered +on the other. This adds the W3C Trace Context standard — generate, parse, and +propagate ``traceparent`` / ``tracestate`` headers so spans, logs, and +downstream services all share one trace and span lineage. + +Pure standard library (``os`` / ``re``); imports no ``PySide6``. ID generation +takes an injectable RNG, so trace and span IDs are deterministic under test. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + new_root_context, child_context, inject_context, extract_context, + parse_traceparent, format_traceparent, + ) + + # Start a trace and propagate it onto an outgoing request: + ctx = new_root_context() + headers = inject_context({"accept": "application/json"}, ctx) + # headers["traceparent"] == "00-<32 hex>-<16 hex>-01" + + # On the receiving side, continue the same trace: + parent = extract_context(request_headers) + if parent is not None: + span = child_context(parent) # same trace_id, new span_id + +``SpanContext`` is the immutable (``trace_id``, ``span_id``, ``trace_flags``, +``tracestate``) tuple. ``new_root_context`` mints a fresh trace; ``child_context`` +keeps the trace id and inherited state but allocates a new span id. +``parse_traceparent`` / ``format_traceparent`` round-trip the version-``00`` +header (rejecting bad versions, malformed or all-zero IDs with +``TraceContextError``); ``parse_tracestate`` / ``format_tracestate`` handle the +vendor list. ``inject_context`` writes the headers; ``extract_context`` reads +them back (case-insensitively). + +Executor commands +----------------- + +``AC_trace_inject`` propagates a context onto outgoing ``headers`` — with a +``traceparent`` it derives a child of that parent, otherwise it starts a fresh +root — and returns ``{headers, traceparent, trace_id, span_id}``. +``AC_trace_extract`` reads a context back out of request ``headers`` and returns +``{context}`` (or ``null`` when no ``traceparent`` is present). Both are exposed +as MCP tools (``ac_trace_inject`` / ``ac_trace_extract``) and as Script Builder +commands under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index dc6401b5..01454de3 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -98,6 +98,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v73_features_doc doc/new_features/v74_features_doc doc/new_features/v75_features_doc + doc/new_features/v76_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v76_features_doc.rst b/docs/source/Zh/doc/new_features/v76_features_doc.rst new file mode 100644 index 00000000..004d3d9f --- /dev/null +++ b/docs/source/Zh/doc/new_features/v76_features_doc.rst @@ -0,0 +1,43 @@ +W3C Trace Context 傳播 +===================== + +``observability`` 追蹤器與 ``agent_trace`` 的 span 都不帶任何 ID,因此一次 HTTP 呼叫一端的 span +無法與它在另一端觸發的工作關聯起來。本功能加入 W3C Trace Context 標準 —— 產生、解析並傳播 +``traceparent`` / ``tracestate`` 標頭,讓 span、日誌與下游服務共享同一條 trace 與 span 血緣。 + +純標準函式庫(``os`` / ``re``);不匯入 ``PySide6``。ID 產生可注入 RNG,因此 trace 與 span ID 在測試中 +具決定性。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + new_root_context, child_context, inject_context, extract_context, + parse_traceparent, format_traceparent, + ) + + # 開啟一條 trace 並傳播到外送請求: + ctx = new_root_context() + headers = inject_context({"accept": "application/json"}, ctx) + # headers["traceparent"] == "00-<32 hex>-<16 hex>-01" + + # 接收端延續同一條 trace: + parent = extract_context(request_headers) + if parent is not None: + span = child_context(parent) # 相同 trace_id,新的 span_id + +``SpanContext`` 是不可變的(``trace_id``、``span_id``、``trace_flags``、``tracestate``)組合。 +``new_root_context`` 鑄造新 trace;``child_context`` 保留 trace id 與繼承狀態但配置新的 span id。 +``parse_traceparent`` / ``format_traceparent`` 來回轉換 version-``00`` 標頭(對錯誤版本、格式不符或全零 +ID 拋出 ``TraceContextError``);``parse_tracestate`` / ``format_tracestate`` 處理 vendor 清單。 +``inject_context`` 寫入標頭;``extract_context`` 將其讀回(不分大小寫)。 + +執行器命令 +---------- + +``AC_trace_inject`` 把 context 傳播到外送 ``headers`` —— 帶 ``traceparent`` 時衍生該父節點的子 span, +否則開啟新的 root —— 回傳 ``{headers, traceparent, trace_id, span_id}``。``AC_trace_extract`` 從請求 +``headers`` 讀回 context,回傳 ``{context}``(無 ``traceparent`` 時為 ``null``)。兩者皆以 MCP 工具 +(``ac_trace_inject`` / ``ac_trace_extract``)以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 31737bc1..405462d1 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -98,6 +98,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v73_features_doc doc/new_features/v74_features_doc doc/new_features/v75_features_doc + doc/new_features/v76_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index b88fa3ac..9253525b 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -377,6 +377,12 @@ ) # HTTP record/replay cassette (deterministic offline API tests) from je_auto_control.utils.http_cassette import Cassette, CassetteMissError +# W3C Trace Context propagation (traceparent / tracestate) +from je_auto_control.utils.trace_context import ( + SpanContext, TraceContextError, child_context, extract_context, + format_traceparent, format_tracestate, inject_context, new_root_context, + new_span_id, new_trace_id, parse_traceparent, parse_tracestate, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -877,6 +883,10 @@ def start_autocontrol_gui(*args, **kwargs): "Bulkhead", "BulkheadFullError", "next_delay", "parse_ratelimit", "parse_retry_after", "Cassette", "CassetteMissError", + "SpanContext", "TraceContextError", "child_context", "extract_context", + "format_traceparent", "format_tracestate", "inject_context", + "new_root_context", "new_span_id", "new_trace_id", "parse_traceparent", + "parse_tracestate", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 5ade50d4..1dd52358 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1606,6 +1606,24 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Replay a recorded HTTP response from a cassette (no network).", )) + specs.append(CommandSpec( + "AC_trace_inject", "Data", "Trace Context: Inject", + fields=( + FieldSpec("headers", FieldType.STRING, optional=True, + placeholder='{"accept": "application/json"}'), + FieldSpec("traceparent", FieldType.STRING, optional=True, + placeholder="00-<32 hex>-<16 hex>-01 (parent; omit for root)"), + ), + description="Set a W3C traceparent on outgoing headers (root or child).", + )) + specs.append(CommandSpec( + "AC_trace_extract", "Data", "Trace Context: Extract", + fields=( + FieldSpec("headers", FieldType.STRING, + placeholder='{"traceparent": "00-...-...-01"}'), + ), + description="Extract the W3C trace context from request headers.", + )) specs.append(CommandSpec( "AC_rate_limit", "Flow", "Rate Limit (Token Bucket)", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 3b23bbf8..68baa875 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2971,6 +2971,35 @@ def _http_replay(cassette: Any, url: str, return {"response": response} +def _trace_inject(headers: Any = None, + traceparent: Any = None) -> Dict[str, Any]: + """Adapter: propagate a trace context into outgoing headers. + + With ``traceparent`` set, derive a child span of that parent; otherwise + start a fresh root. Returns the updated ``headers`` plus the new ids. + """ + import json + from je_auto_control.utils.trace_context import ( + child_context, inject_context, new_root_context, parse_traceparent) + if isinstance(headers, str): + headers = json.loads(headers) + ctx = (child_context(parse_traceparent(traceparent)) + if traceparent else new_root_context()) + return {"headers": inject_context(headers, ctx), + "traceparent": inject_context({}, ctx)["traceparent"], + "trace_id": ctx.trace_id, "span_id": ctx.span_id} + + +def _trace_extract(headers: Any) -> Dict[str, Any]: + """Adapter: extract a trace context from request headers.""" + import json + from je_auto_control.utils.trace_context import extract_context + if isinstance(headers, str): + headers = json.loads(headers) + ctx = extract_context(headers) + return {"context": ctx.to_dict() if ctx is not None else None} + + def _percentiles(samples: Any, qs: Any = None) -> Dict[str, Any]: """Adapter: exact percentiles of a numeric sample list (or JSON string).""" import json @@ -4047,6 +4076,8 @@ def __init__(self): "AC_bulkhead_run": _bulkhead_run, "AC_retry_after": _retry_after, "AC_http_replay": _http_replay, + "AC_trace_inject": _trace_inject, + "AC_trace_extract": _trace_extract, "AC_unified_diff": _unified_diff, "AC_apply_unified": _apply_unified, "AC_three_way_merge": _three_way_merge, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index aae4b449..b69a2071 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,32 @@ def rate_limit_tools() -> List[MCPTool]: ] +def trace_context_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_trace_inject", + description=("Propagate a W3C trace context into outgoing 'headers'. " + "With 'traceparent' set, derive a child span of that " + "parent; else start a fresh root. Returns updated " + "{headers, traceparent, trace_id, span_id}."), + input_schema=schema( + {"headers": {"type": "object"}, + "traceparent": {"type": "string"}}, + []), + handler=h.trace_inject, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_trace_extract", + description=("Extract a W3C trace context from request 'headers'. " + "Returns {context} (or null when no traceparent)."), + input_schema=schema({"headers": {"type": "object"}}, ["headers"]), + handler=h.trace_extract, + annotations=READ_ONLY, + ), + ] + + def http_cassette_tools() -> List[MCPTool]: return [ MCPTool( @@ -4878,6 +4904,7 @@ def media_assert_tools() -> List[MCPTool]: search_index_tools, stats_tools, recurrence_tools, text_diff_tools, feature_flag_tools, provenance_tools, json_contract_tools, chaos_tools, slo_tools, percentiles_tools, bulkhead_tools, http_cassette_tools, + trace_context_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index e081e86d..b32a8790 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1716,6 +1716,16 @@ def http_replay(cassette, url, method="GET"): return {"response": response} +def trace_inject(headers=None, traceparent=None): + from je_auto_control.utils.executor.action_executor import _trace_inject + return _trace_inject(headers, traceparent) + + +def trace_extract(headers): + from je_auto_control.utils.executor.action_executor import _trace_extract + return _trace_extract(headers) + + def build_provenance(paths, builder_id="je_auto_control"): from je_auto_control.utils.provenance import build_provenance, subject_for subjects = [subject_for(path) for path in paths] diff --git a/je_auto_control/utils/trace_context/__init__.py b/je_auto_control/utils/trace_context/__init__.py new file mode 100644 index 00000000..ab88e76a --- /dev/null +++ b/je_auto_control/utils/trace_context/__init__.py @@ -0,0 +1,13 @@ +"""W3C Trace Context propagation for AutoControl runs.""" +from je_auto_control.utils.trace_context.trace_context import ( + SpanContext, TraceContextError, child_context, extract_context, + format_traceparent, format_tracestate, inject_context, new_root_context, + new_span_id, new_trace_id, parse_traceparent, parse_tracestate, +) + +__all__ = [ + "SpanContext", "TraceContextError", "child_context", "extract_context", + "format_traceparent", "format_tracestate", "inject_context", + "new_root_context", "new_span_id", "new_trace_id", "parse_traceparent", + "parse_tracestate", +] diff --git a/je_auto_control/utils/trace_context/trace_context.py b/je_auto_control/utils/trace_context/trace_context.py new file mode 100644 index 00000000..1235c17c --- /dev/null +++ b/je_auto_control/utils/trace_context/trace_context.py @@ -0,0 +1,149 @@ +"""W3C Trace Context (traceparent / tracestate) propagation. + +The existing ``observability`` tracer and ``agent_trace`` spans carry no IDs and +cannot be correlated across an HTTP boundary. This module implements the W3C +Trace Context standard so a run can generate, parse, and propagate +``traceparent`` / ``tracestate`` headers — the keystone that lets spans, logs, +and downstream services share one trace. + +Pure standard library (``os`` / ``re``); imports no ``PySide6``. ID generation +accepts an injectable RNG so trace/span IDs are deterministic under test. +""" +import os +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Tuple + +from je_auto_control.utils.exception.exceptions import AutoControlException + +_VERSION = "00" +_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$") +_SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$") +_FLAGS_RE = re.compile(r"^[0-9a-f]{2}$") +_TRACESTATE_KEY_RE = re.compile(r"^[a-z0-9][_0-9a-z\-*/]{0,255}$") +_FLAG_SAMPLED = 0x01 + +RandBytes = Callable[[int], bytes] + + +class TraceContextError(AutoControlException): + """A traceparent / tracestate header was malformed.""" + + +@dataclass(frozen=True) +class SpanContext: + """An immutable W3C trace context identifying one span within a trace.""" + + trace_id: str + span_id: str + trace_flags: int = _FLAG_SAMPLED + tracestate: List[Tuple[str, str]] = field(default_factory=list) + + @property + def sampled(self) -> bool: + """Whether the sampled flag bit is set.""" + return bool(self.trace_flags & _FLAG_SAMPLED) + + def to_dict(self) -> Dict[str, Any]: + """Return a JSON-friendly view of the context.""" + return {"trace_id": self.trace_id, "span_id": self.span_id, + "trace_flags": self.trace_flags, "sampled": self.sampled, + "tracestate": [list(item) for item in self.tracestate]} + + +def _rand_hex(n_bytes: int, rng: Optional[RandBytes]) -> str: + raw = (rng(n_bytes) if rng is not None else os.urandom(n_bytes)) + value = raw[:n_bytes].rjust(n_bytes, b"\x00") + if not any(value): # all-zero IDs are invalid + value = (b"\x00" * (n_bytes - 1)) + b"\x01" + return value.hex() + + +def new_trace_id(rng: Optional[RandBytes] = None) -> str: + """Return a random 16-byte (32 hex) trace id.""" + return _rand_hex(16, rng) + + +def new_span_id(rng: Optional[RandBytes] = None) -> str: + """Return a random 8-byte (16 hex) span id.""" + return _rand_hex(8, rng) + + +def new_root_context(rng: Optional[RandBytes] = None, *, + sampled: bool = True) -> SpanContext: + """Create a fresh root span context with new trace and span IDs.""" + return SpanContext(new_trace_id(rng), new_span_id(rng), + _FLAG_SAMPLED if sampled else 0) + + +def child_context(parent: SpanContext, + rng: Optional[RandBytes] = None) -> SpanContext: + """Create a child context: same trace, new span id, inherited state.""" + return SpanContext(parent.trace_id, new_span_id(rng), parent.trace_flags, + list(parent.tracestate)) + + +def parse_tracestate(header: Optional[str]) -> List[Tuple[str, str]]: + """Parse a ``tracestate`` header into an ordered list of (key, value).""" + items: List[Tuple[str, str]] = [] + for member in (header or "").split(","): + member = member.strip() + if not member: + continue + key, sep, value = member.partition("=") + if sep and _TRACESTATE_KEY_RE.match(key.strip()): + items.append((key.strip(), value.strip())) + return items[:32] + + +def format_tracestate(items: List[Tuple[str, str]]) -> str: + """Serialise (key, value) pairs into a ``tracestate`` header value.""" + return ",".join(f"{key}={value}" for key, value in items[:32]) + + +def parse_traceparent(header: str) -> SpanContext: + """Parse a ``traceparent`` header into a :class:`SpanContext`.""" + parts = (header or "").strip().split("-") + if len(parts) != 4: + raise TraceContextError(f"traceparent must have 4 fields: {header!r}") + version, trace_id, span_id, flags = parts + _validate_traceparent_fields(version, trace_id, span_id, flags) + return SpanContext(trace_id, span_id, int(flags, 16)) + + +def _validate_traceparent_fields(version: str, trace_id: str, span_id: str, + flags: str) -> None: + if version != _VERSION: + raise TraceContextError(f"unsupported traceparent version: {version!r}") + if not _TRACE_ID_RE.match(trace_id) or trace_id == "0" * 32: + raise TraceContextError(f"invalid trace id: {trace_id!r}") + if not _SPAN_ID_RE.match(span_id) or span_id == "0" * 16: + raise TraceContextError(f"invalid span id: {span_id!r}") + if not _FLAGS_RE.match(flags): + raise TraceContextError(f"invalid trace flags: {flags!r}") + + +def format_traceparent(ctx: SpanContext) -> str: + """Serialise a :class:`SpanContext` into a ``traceparent`` header value.""" + return f"{_VERSION}-{ctx.trace_id}-{ctx.span_id}-{ctx.trace_flags:02x}" + + +def inject_context(headers: Optional[Dict[str, str]], + ctx: SpanContext) -> Dict[str, str]: + """Return ``headers`` with ``traceparent`` (+ ``tracestate``) set.""" + out = dict(headers or {}) + out["traceparent"] = format_traceparent(ctx) + if ctx.tracestate: + out["tracestate"] = format_tracestate(ctx.tracestate) + return out + + +def extract_context(headers: Optional[Dict[str, str]]) -> Optional[SpanContext]: + """Extract a :class:`SpanContext` from request headers, or ``None``.""" + lookup = {str(key).lower(): value for key, value in (headers or {}).items()} + raw = lookup.get("traceparent") + if not raw: + return None + ctx = parse_traceparent(raw) + state = parse_tracestate(lookup.get("tracestate")) + return SpanContext(ctx.trace_id, ctx.span_id, ctx.trace_flags, state) diff --git a/test/unit_test/headless/test_trace_context_batch.py b/test/unit_test/headless/test_trace_context_batch.py new file mode 100644 index 00000000..e071f5cc --- /dev/null +++ b/test/unit_test/headless/test_trace_context_batch.py @@ -0,0 +1,112 @@ +"""Headless tests for W3C Trace Context propagation. Pure stdlib, no Qt.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.trace_context import ( + SpanContext, TraceContextError, child_context, extract_context, + format_traceparent, inject_context, new_root_context, parse_traceparent, + parse_tracestate, +) + + +def _seeded_rng(): + import random + state = random.Random(1234) + return lambda n: bytes(state.getrandbits(8) for _ in range(n)) + + +def test_roundtrip_parse_format(): + header = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + ctx = parse_traceparent(header) + assert ctx.trace_id == "4bf92f3577b34da6a3ce929d0e0e4736" + assert ctx.span_id == "00f067aa0ba902b7" + assert ctx.sampled is True + assert format_traceparent(ctx) == header + + +def test_new_root_is_deterministic_with_injected_rng(): + rng = _seeded_rng() + ctx = new_root_context(rng) + again = new_root_context(_seeded_rng()) + assert ctx.trace_id == again.trace_id and ctx.span_id == again.span_id + assert len(ctx.trace_id) == 32 and len(ctx.span_id) == 16 + + +def test_child_keeps_trace_changes_span(): + parent = new_root_context(_seeded_rng()) + child = child_context(parent) + assert child.trace_id == parent.trace_id # same trace + assert child.span_id != parent.span_id # new span + assert child.trace_flags == parent.trace_flags + + +@pytest.mark.parametrize("bad", [ + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7", # 3 fields + "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", # version + "00-00000000000000000000000000000000-00f067aa0ba902b7-01", # zero trace + "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01", # zero span + "00-zz-00f067aa0ba902b7-01", # non-hex +]) +def test_invalid_traceparent_raises(bad): + with pytest.raises(TraceContextError): + parse_traceparent(bad) + + +def test_tracestate_parse_and_inject(): + state = parse_tracestate("rojo=00f067aa0ba902b7,congo=t61rcWkgMzE") + assert state == [("rojo", "00f067aa0ba902b7"), ("congo", "t61rcWkgMzE")] + ctx = SpanContext("4bf92f3577b34da6a3ce929d0e0e4736", + "00f067aa0ba902b7", 1, state) + headers = inject_context({"accept": "application/json"}, ctx) + assert headers["tracestate"] == "rojo=00f067aa0ba902b7,congo=t61rcWkgMzE" + assert headers["accept"] == "application/json" + + +def test_extract_is_case_insensitive_and_roundtrips(): + ctx = new_root_context(_seeded_rng()) + headers = inject_context({}, ctx) + extracted = extract_context({"TraceParent": headers["traceparent"]}) + assert extracted.trace_id == ctx.trace_id + assert extract_context({"x": "y"}) is None + + +# --- wiring --------------------------------------------------------------- + +def test_executor_inject_then_extract(): + rec = ac.execute_action([["AC_trace_inject", {}]]) + out = next(v for v in rec.values() if isinstance(v, dict)) + assert out["trace_id"] and out["headers"]["traceparent"] + rec2 = ac.execute_action([[ + "AC_trace_extract", + {"headers": json.dumps(out["headers"])}, + ]]) + ctx = next(v for v in rec2.values() if isinstance(v, dict))["context"] + assert ctx["trace_id"] == out["trace_id"] + + +def test_executor_child_of_parent(): + parent = format_traceparent(new_root_context()) + rec = ac.execute_action([[ + "AC_trace_inject", {"traceparent": parent}]]) + out = next(v for v in rec.values() if isinstance(v, dict)) + assert out["trace_id"] == parent.split("-")[1] # same trace + assert out["span_id"] != parent.split("-")[2] # new span + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_trace_inject", "AC_trace_extract"} <= set(known) + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_trace_inject", "ac_trace_extract"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_trace_inject", "AC_trace_extract"} <= specs + + +def test_facade_exports(): + for attr in ("SpanContext", "TraceContextError", "new_root_context", + "parse_traceparent", "inject_context", "extract_context"): + assert hasattr(ac, attr) and attr in ac.__all__ From d6c4d1fea9ffa48054389f943840b6fd790c6963 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 21:47:57 +0800 Subject: [PATCH 2/2] Use deterministic byte source in trace-context test (drop random) --- test/unit_test/headless/test_trace_context_batch.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/unit_test/headless/test_trace_context_batch.py b/test/unit_test/headless/test_trace_context_batch.py index e071f5cc..3951506e 100644 --- a/test/unit_test/headless/test_trace_context_batch.py +++ b/test/unit_test/headless/test_trace_context_batch.py @@ -12,9 +12,10 @@ def _seeded_rng(): - import random - state = random.Random(1234) - return lambda n: bytes(state.getrandbits(8) for _ in range(n)) + # deterministic byte source for reproducible IDs (no `random` module) + import itertools + counter = itertools.count(1) + return lambda n: bytes(next(counter) % 256 for _ in range(n)) def test_roundtrip_parse_format():