From b5e9e609a3010b259baf38cae82583b27c1e03fa Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 22 Jun 2026 03:40:49 +0800 Subject: [PATCH] Add OTLP/JSON span export agent_trace.to_otel returned flat span dicts that are not valid OTLP/JSON (no resourceSpans/scopeSpans nesting, no proper attribute encoding, times not as uint64 strings). Add spans_to_otlp / attributes_to_otlp / write_otlp to shape spans into the envelope an OpenTelemetry collector ingests via its file exporter (hex ids, uint64-string times, KeyValue attributes). Wired through facade, executor (AC_spans_to_otlp), 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/v94_features_doc.rst | 42 ++++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v94_features_doc.rst | 38 +++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 ++ .../gui/script_builder/command_schema.py | 12 +++ .../utils/executor/action_executor.py | 12 +++ .../utils/mcp_server/tools/_factories.py | 22 ++++- .../utils/mcp_server/tools/_handlers.py | 5 ++ je_auto_control/utils/otlp_export/__init__.py | 6 ++ .../utils/otlp_export/otlp_export.py | 75 +++++++++++++++++ .../headless/test_otlp_export_batch.py | 84 +++++++++++++++++++ 15 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 docs/source/Eng/doc/new_features/v94_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v94_features_doc.rst create mode 100644 je_auto_control/utils/otlp_export/__init__.py create mode 100644 je_auto_control/utils/otlp_export/otlp_export.py create mode 100644 test/unit_test/headless/test_otlp_export_batch.py diff --git a/README.md b/README.md index ec4ff77a..862990b8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-22) — OTLP/JSON Span Export](#whats-new-2026-06-22--otlpjson-span-export) - [What's new (2026-06-22) — Canonical Log Lines & Structured Logging](#whats-new-2026-06-22--canonical-log-lines--structured-logging) - [What's new (2026-06-22) — Conditional HTTP Requests & Cache Validators](#whats-new-2026-06-22--conditional-http-requests--cache-validators) - [What's new (2026-06-22) — Cookie Jar (HTTP Session Carry)](#whats-new-2026-06-22--cookie-jar-http-session-carry) @@ -146,6 +147,12 @@ --- +## What's new (2026-06-22) — OTLP/JSON Span Export + +Export spans the way a collector ingests them. Full reference: [`docs/source/Eng/doc/new_features/v94_features_doc.rst`](docs/source/Eng/doc/new_features/v94_features_doc.rst). + +- **`spans_to_otlp` / `attributes_to_otlp` / `write_otlp`** (`AC_spans_to_otlp`): `agent_trace.to_otel` returned flat dicts that aren't valid OTLP/JSON (no resourceSpans/scopeSpans nesting, times not as uint64 strings). This wraps spans in the proper envelope with hex IDs, uint64-string times, and OTLP `KeyValue` attribute encoding — what an OpenTelemetry collector's file exporter reads. Pairs with `trace_context`. Pure-stdlib, deterministic. + ## What's new (2026-06-22) — Canonical Log Lines & Structured Logging One wide event per run, with trace correlation. Full reference: [`docs/source/Eng/doc/new_features/v93_features_doc.rst`](docs/source/Eng/doc/new_features/v93_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 8b339657..51654d30 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-22) — OTLP/JSON Span 导出](#本次更新-2026-06-22--otlpjson-span-导出) - [本次更新 (2026-06-22) — 标准日志行与结构化日志](#本次更新-2026-06-22--标准日志行与结构化日志) - [本次更新 (2026-06-22) — 条件式 HTTP 请求与缓存验证子](#本次更新-2026-06-22--条件式-http-请求与缓存验证子) - [本次更新 (2026-06-22) — Cookie Jar(HTTP 会话携带)](#本次更新-2026-06-22--cookie-jarhttp-会话携带) @@ -145,6 +146,12 @@ --- +## 本次更新 (2026-06-22) — OTLP/JSON Span 导出 + +以 collector 摄取的格式导出 span。完整参考:[`docs/source/Zh/doc/new_features/v94_features_doc.rst`](../docs/source/Zh/doc/new_features/v94_features_doc.rst)。 + +- **`spans_to_otlp` / `attributes_to_otlp` / `write_otlp`**(`AC_spans_to_otlp`):`agent_trace.to_otel` 返回扁平 dict,并非有效 OTLP/JSON(没有 resourceSpans/scopeSpans 嵌套、时间不是 uint64 字符串)。本功能把 span 包进正确封套,含 hex ID、uint64 字符串时间,以及 OTLP `KeyValue` 属性编码 —— OpenTelemetry collector file exporter 读取的格式。与 `trace_context` 搭配。纯标准库、确定。 + ## 本次更新 (2026-06-22) — 标准日志行与结构化日志 每次执行一行宽事件,并带 trace 关联。完整参考:[`docs/source/Zh/doc/new_features/v93_features_doc.rst`](../docs/source/Zh/doc/new_features/v93_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index a6dc79f1..ef1469a0 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-22) — OTLP/JSON Span 匯出](#本次更新-2026-06-22--otlpjson-span-匯出) - [本次更新 (2026-06-22) — 標準日誌行與結構化日誌](#本次更新-2026-06-22--標準日誌行與結構化日誌) - [本次更新 (2026-06-22) — 條件式 HTTP 請求與快取驗證子](#本次更新-2026-06-22--條件式-http-請求與快取驗證子) - [本次更新 (2026-06-22) — Cookie Jar(HTTP 工作階段攜帶)](#本次更新-2026-06-22--cookie-jarhttp-工作階段攜帶) @@ -145,6 +146,12 @@ --- +## 本次更新 (2026-06-22) — OTLP/JSON Span 匯出 + +以 collector 攝取的格式匯出 span。完整參考:[`docs/source/Zh/doc/new_features/v94_features_doc.rst`](../docs/source/Zh/doc/new_features/v94_features_doc.rst)。 + +- **`spans_to_otlp` / `attributes_to_otlp` / `write_otlp`**(`AC_spans_to_otlp`):`agent_trace.to_otel` 回傳扁平 dict,並非有效 OTLP/JSON(沒有 resourceSpans/scopeSpans 巢狀、時間不是 uint64 字串)。本功能把 span 包進正確封套,含 hex ID、uint64 字串時間,以及 OTLP `KeyValue` 屬性編碼 —— OpenTelemetry collector file exporter 讀取的格式。與 `trace_context` 搭配。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-22) — 標準日誌行與結構化日誌 每次執行一行寬事件,並帶 trace 關聯。完整參考:[`docs/source/Zh/doc/new_features/v93_features_doc.rst`](../docs/source/Zh/doc/new_features/v93_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v94_features_doc.rst b/docs/source/Eng/doc/new_features/v94_features_doc.rst new file mode 100644 index 00000000..5c09ad11 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v94_features_doc.rst @@ -0,0 +1,42 @@ +OTLP/JSON Span Export +===================== + +``agent_trace.to_otel`` returns flat span dicts that are not valid OTLP/JSON +(no ``resourceSpans`` / ``scopeSpans`` nesting, no proper attribute encoding, +times not as uint64 strings). This shapes a list of spans into the envelope an +OpenTelemetry collector ingests directly via its file exporter. + +Pure standard library (``json``); imports no ``PySide6``. Times are supplied by +the caller (no wall clock), so the envelope is byte-stable and CI-testable. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import spans_to_otlp, write_otlp + + spans = [{ + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "span_id": "00f067aa0ba902b7", + "name": "run_suite", + "start_unix_nano": started_ns, "end_unix_nano": ended_ns, + "attributes": {"ok": True, "cases": 12}, + }] + payload = spans_to_otlp(spans, resource_attrs={"service.name": "autocontrol"}) + write_otlp(payload, "trace.otlp.json") + +``spans_to_otlp`` wraps spans in the ``resourceSpans → scopeSpans → spans`` +structure: trace/span IDs stay hex, times become uint64 strings, and attributes +are encoded as OTLP ``KeyValue`` entries (``stringValue`` / ``intValue`` / +``boolValue`` / ``doubleValue``). ``attributes_to_otlp`` exposes that attribute +conversion, and ``write_otlp`` writes the payload as JSON. The result is what an +OpenTelemetry collector's file exporter reads — pairing with ``trace_context`` +for the IDs and ``agent_trace`` for the span data. + +Executor command +---------------- + +``AC_spans_to_otlp`` wraps ``spans`` (with optional ``resource_attrs``) into +``{payload}``. It is exposed as the MCP tool ``ac_spans_to_otlp`` and as a +Script Builder command under **Report**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index b67ba055..d64f0020 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -116,6 +116,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v91_features_doc doc/new_features/v92_features_doc doc/new_features/v93_features_doc + doc/new_features/v94_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/v94_features_doc.rst b/docs/source/Zh/doc/new_features/v94_features_doc.rst new file mode 100644 index 00000000..bfc421c5 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v94_features_doc.rst @@ -0,0 +1,38 @@ +OTLP/JSON Span 匯出 +================== + +``agent_trace.to_otel`` 回傳的是扁平 span dict,並非有效的 OTLP/JSON(沒有 ``resourceSpans`` / +``scopeSpans`` 巢狀、屬性未正確編碼、時間不是 uint64 字串)。本功能把一串 span 塑形成 OpenTelemetry +collector 可透過 file exporter 直接攝取的封套。 + +純標準函式庫(``json``);不匯入 ``PySide6``。時間由呼叫端提供(不使用 wall clock),因此封套位元組穩定、 +可於 CI 測試。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import spans_to_otlp, write_otlp + + spans = [{ + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "span_id": "00f067aa0ba902b7", + "name": "run_suite", + "start_unix_nano": started_ns, "end_unix_nano": ended_ns, + "attributes": {"ok": True, "cases": 12}, + }] + payload = spans_to_otlp(spans, resource_attrs={"service.name": "autocontrol"}) + write_otlp(payload, "trace.otlp.json") + +``spans_to_otlp`` 把 span 包進 ``resourceSpans → scopeSpans → spans`` 結構:trace/span ID 維持 hex、 +時間轉成 uint64 字串、屬性編碼為 OTLP ``KeyValue``(``stringValue`` / ``intValue`` / ``boolValue`` / +``doubleValue``)。``attributes_to_otlp`` 公開該屬性轉換,``write_otlp`` 把 payload 寫成 JSON。結果即為 +OpenTelemetry collector file exporter 讀取的格式 —— 與 ``trace_context``(提供 ID)及 ``agent_trace`` +(提供 span 資料)搭配。 + +執行器命令 +---------- + +``AC_spans_to_otlp`` 把 ``spans``(以及選用的 ``resource_attrs``)包成 ``{payload}``。它以 MCP 工具 +``ac_spans_to_otlp`` 以及 Script Builder 中 **Report** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 7554a151..5649932b 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -116,6 +116,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v91_features_doc doc/new_features/v92_features_doc doc/new_features/v93_features_doc + doc/new_features/v94_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 d277e374..6521e415 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -450,6 +450,10 @@ from je_auto_control.utils.canonical_log import ( CanonicalLogLine, JSONLogFormatter, bind_trace_context, ) +# OTLP/JSON span export (resourceSpans envelope) +from je_auto_control.utils.otlp_export import ( + attributes_to_otlp, spans_to_otlp, write_otlp, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -976,6 +980,7 @@ def start_autocontrol_gui(*args, **kwargs): "Baggage", "extract_baggage", "format_baggage", "inject_baggage", "parse_baggage", "CanonicalLogLine", "JSONLogFormatter", "bind_trace_context", + "attributes_to_otlp", "spans_to_otlp", "write_otlp", # 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 f5437d77..740a3d8b 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1648,6 +1648,18 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Build a canonical wide-event log line (rendered as JSON).", )) + specs.append(CommandSpec( + "AC_spans_to_otlp", "Report", "OTLP: Export Spans", + fields=( + FieldSpec("spans", FieldType.STRING, + placeholder='[{"trace_id": "...", "span_id": "...", ' + '"name": "step", "start_unix_nano": 1, ' + '"end_unix_nano": 2}]'), + FieldSpec("resource_attrs", FieldType.STRING, optional=True, + placeholder='{"service.name": "autocontrol"}'), + ), + description="Wrap spans in an OTLP/JSON resourceSpans envelope.", + )) specs.append(CommandSpec( "AC_resolve_ref", "Security", "Secret Ref: Resolve", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index d355aaa1..ed82360f 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3137,6 +3137,17 @@ def _canonical_log(fields: Any) -> Dict[str, Any]: return {"line": line.to_dict(), "json": line.render()} +def _spans_to_otlp(spans: Any, resource_attrs: Any = None) -> Dict[str, Any]: + """Adapter: wrap spans in an OTLP/JSON resourceSpans envelope.""" + import json + from je_auto_control.utils.otlp_export import spans_to_otlp + if isinstance(spans, str): + spans = json.loads(spans) + if isinstance(resource_attrs, str): + resource_attrs = json.loads(resource_attrs) + return {"payload": spans_to_otlp(spans, resource_attrs=resource_attrs)} + + def _baggage_format(items: Any) -> Dict[str, Any]: """Adapter: serialise an items dict into a W3C baggage {header}.""" import json @@ -4389,6 +4400,7 @@ def __init__(self): "AC_baggage_parse": _baggage_parse, "AC_baggage_format": _baggage_format, "AC_canonical_log": _canonical_log, + "AC_spans_to_otlp": _spans_to_otlp, "AC_resolve_ref": _resolve_ref, "AC_resolve_refs": _resolve_refs, "AC_redact_config": _redact_config, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 0f10a604..acd027d4 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3731,6 +3731,24 @@ def secret_ref_tools() -> List[MCPTool]: ] +def otlp_export_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_spans_to_otlp", + description=("Wrap 'spans' (each {trace_id, span_id, name, " + "start_unix_nano, end_unix_nano, attributes?}) in an " + "OTLP/JSON resourceSpans envelope; optional " + "'resource_attrs'. Returns {payload}."), + input_schema=schema( + {"spans": {"type": "array"}, + "resource_attrs": {"type": "object"}}, + ["spans"]), + handler=h.spans_to_otlp, + annotations=READ_ONLY, + ), + ] + + def canonical_log_tools() -> List[MCPTool]: return [ MCPTool( @@ -5326,8 +5344,8 @@ 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, baggage_tools, canonical_log_tools, secret_ref_tools, - config_redaction_tools, + trace_context_tools, baggage_tools, canonical_log_tools, otlp_export_tools, + secret_ref_tools, config_redaction_tools, data_profile_tools, http_problem_tools, dotenv_tools, sse_client_tools, layered_config_tools, data_drift_tools, dataset_diff_tools, referential_tools, link_header_tools, multipart_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 23eb3e38..aa22266d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1741,6 +1741,11 @@ def canonical_log(fields): return _canonical_log(fields) +def spans_to_otlp(spans, resource_attrs=None): + from je_auto_control.utils.executor.action_executor import _spans_to_otlp + return _spans_to_otlp(spans, resource_attrs) + + def resolve_ref(ref): from je_auto_control.utils.executor.action_executor import _resolve_ref return _resolve_ref(ref) diff --git a/je_auto_control/utils/otlp_export/__init__.py b/je_auto_control/utils/otlp_export/__init__.py new file mode 100644 index 00000000..78011fd5 --- /dev/null +++ b/je_auto_control/utils/otlp_export/__init__.py @@ -0,0 +1,6 @@ +"""OTLP/JSON span export for AutoControl.""" +from je_auto_control.utils.otlp_export.otlp_export import ( + attributes_to_otlp, spans_to_otlp, write_otlp, +) + +__all__ = ["attributes_to_otlp", "spans_to_otlp", "write_otlp"] diff --git a/je_auto_control/utils/otlp_export/otlp_export.py b/je_auto_control/utils/otlp_export/otlp_export.py new file mode 100644 index 00000000..92c204b2 --- /dev/null +++ b/je_auto_control/utils/otlp_export/otlp_export.py @@ -0,0 +1,75 @@ +"""Render spans into the OTLP/JSON ``resourceSpans`` envelope. + +``agent_trace.to_otel`` returns flat span dicts that are not valid OTLP/JSON +(no ``resourceSpans`` / ``scopeSpans`` nesting, no proper attribute encoding, +times not as uint64 strings). This shapes a list of spans into the envelope an +OpenTelemetry collector ingests directly via its file exporter. + +Pure standard library (``json``); imports no ``PySide6``. Times are supplied by +the caller (no wall clock), so the envelope is byte-stable and CI-testable. +""" +import json +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + + +def _attr_value(value: Any) -> Dict[str, Any]: + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, int): + return {"intValue": str(value)} # int64 encoded as string + if isinstance(value, float): + return {"doubleValue": value} + return {"stringValue": str(value)} + + +def attributes_to_otlp(attributes: Optional[Mapping[str, Any]] + ) -> List[Dict[str, Any]]: + """Convert a plain attribute dict to an OTLP KeyValue list.""" + return [{"key": str(key), "value": _attr_value(value)} + for key, value in (attributes or {}).items()] + + +def _span_to_otlp(span: Mapping[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = { + "traceId": span["trace_id"], + "spanId": span["span_id"], + "name": span.get("name", ""), + "kind": int(span.get("kind", 1)), + "startTimeUnixNano": str(span.get("start_unix_nano", 0)), + "endTimeUnixNano": str(span.get("end_unix_nano", 0)), + "attributes": attributes_to_otlp(span.get("attributes")), + } + if span.get("parent_span_id"): + out["parentSpanId"] = span["parent_span_id"] + return out + + +def spans_to_otlp(spans: Sequence[Mapping[str, Any]], *, + resource_attrs: Optional[Mapping[str, Any]] = None, + scope_name: str = "je_auto_control", + scope_version: str = "") -> Dict[str, Any]: + """Wrap ``spans`` in an OTLP/JSON ``resourceSpans`` envelope. + + Each span is a dict with ``trace_id`` / ``span_id`` (hex), ``name``, + ``start_unix_nano`` / ``end_unix_nano`` and optional ``attributes`` / + ``parent_span_id`` / ``kind``. + """ + scope: Dict[str, Any] = {"name": scope_name} + if scope_version: + scope["version"] = scope_version + return {"resourceSpans": [{ + "resource": {"attributes": attributes_to_otlp(resource_attrs)}, + "scopeSpans": [{ + "scope": scope, + "spans": [_span_to_otlp(span) for span in spans], + }], + }]} + + +def write_otlp(payload: Mapping[str, Any], path: str) -> str: + """Write an OTLP payload to ``path`` as JSON; return the path.""" + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return str(out) diff --git a/test/unit_test/headless/test_otlp_export_batch.py b/test/unit_test/headless/test_otlp_export_batch.py new file mode 100644 index 00000000..034a1f4a --- /dev/null +++ b/test/unit_test/headless/test_otlp_export_batch.py @@ -0,0 +1,84 @@ +"""Headless tests for OTLP/JSON span export. Pure stdlib, no Qt.""" +import json +from pathlib import Path + +import je_auto_control as ac +from je_auto_control.utils.otlp_export import ( + attributes_to_otlp, spans_to_otlp, write_otlp, +) + +_SPAN = { + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "span_id": "00f067aa0ba902b7", + "parent_span_id": "00f067aa0ba902b8", + "name": "execute", + "start_unix_nano": 1700000000000000000, + "end_unix_nano": 1700000000500000000, + "attributes": {"ok": True, "count": 3, "ratio": 0.5, "label": "x"}, +} + + +def test_attributes_to_otlp_typing(): + attrs = {a["key"]: a["value"] for a in + attributes_to_otlp({"s": "v", "i": 7, "b": True, "d": 1.5})} + assert attrs["s"] == {"stringValue": "v"} + assert attrs["i"] == {"intValue": "7"} # int64 as string + assert attrs["b"] == {"boolValue": True} + assert attrs["d"] == {"doubleValue": 1.5} + + +def test_spans_to_otlp_envelope_shape(): + payload = spans_to_otlp([_SPAN], + resource_attrs={"service.name": "autocontrol"}) + resource_spans = payload["resourceSpans"][0] + assert resource_spans["resource"]["attributes"][0]["key"] == "service.name" + scope_spans = resource_spans["scopeSpans"][0] + assert scope_spans["scope"]["name"] == "je_auto_control" + span = scope_spans["spans"][0] + assert span["traceId"] == _SPAN["trace_id"] + assert span["spanId"] == _SPAN["span_id"] + assert span["parentSpanId"] == _SPAN["parent_span_id"] + assert span["startTimeUnixNano"] == "1700000000000000000" # uint64 string + assert span["kind"] == 1 + + +def test_span_without_parent_omits_field(): + span = dict(_SPAN) + del span["parent_span_id"] + out = spans_to_otlp([span])["resourceSpans"][0]["scopeSpans"][0]["spans"][0] + assert "parentSpanId" not in out + + +def test_scope_version_included_when_set(): + payload = spans_to_otlp([], scope_version="1.2.3") + assert payload["resourceSpans"][0]["scopeSpans"][0]["scope"]["version"] == \ + "1.2.3" + + +def test_write_otlp(tmp_path): + path = write_otlp(spans_to_otlp([_SPAN]), str(tmp_path / "trace.json")) + loaded = json.loads(Path(path).read_text(encoding="utf-8")) + assert "resourceSpans" in loaded + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_spans_to_otlp", {"spans": json.dumps([_SPAN])}]]) + payload = next(v for v in rec.values() if isinstance(v, dict))["payload"] + span = payload["resourceSpans"][0]["scopeSpans"][0]["spans"][0] + assert span["name"] == "execute" + + +def test_wiring(): + assert "AC_spans_to_otlp" in ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + assert "ac_spans_to_otlp" in {t.name for t in build_default_tool_registry()} + from je_auto_control.gui.script_builder.command_schema import _build_specs + assert "AC_spans_to_otlp" in {s.command for s in _build_specs()} + + +def test_facade_exports(): + for attr in ("attributes_to_otlp", "spans_to_otlp", "write_otlp"): + assert hasattr(ac, attr) and attr in ac.__all__