Skip to content

Commit 2fa016b

Browse files
authored
opentelemetry-test-utils: expose Weaver reports and diagnostics (#5739)
* opentelemetry-test-utils: expose Weaver reports and diagnostics Assisted-by: ChatGPT * Name changelog fragment for PR 5739 * Preserve Weaver diagnostics after kill timeout and fix test portability Assisted-by: ChatGPT * Make local --advice-data paths absolute Assisted-by: Claude Opus 4.6 * Keep startup_timeout position and fix Windows test Assisted-by: Claude Opus 4.6 * Specify encoding in advice data test Assisted-by: Claude Opus 4.6
1 parent d8e610a commit 2fa016b

4 files changed

Lines changed: 264 additions & 8 deletions

File tree

‎.changelog/5739.added‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
`opentelemetry-test-utils`: expose live-check reports and retained stdout/stderr,
2+
and add named Weaver configuration and advice-data options.

‎tests/opentelemetry-test-utils/src/opentelemetry/test/weaver_live_check.py‎

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import time
1313
from collections import defaultdict
1414
from collections.abc import Sequence
15+
from copy import deepcopy
1516
from itertools import chain
1617
from math import isfinite
1718
from typing import Any
@@ -108,6 +109,9 @@ def _format_violations(violations: list) -> str:
108109
class LiveCheckError(AssertionError):
109110
"""Raised by :meth:`WeaverLiveCheck.end_and_check` when semconv violations are found.
110111
112+
Captured process output is available as :attr:`stdout` and :attr:`stderr`,
113+
including after the live-check context has closed.
114+
111115
The full :class:`LiveCheckReport` is attached as :attr:`report` for
112116
structured inspection beyond the human-readable message::
113117
@@ -121,9 +125,11 @@ class LiveCheckError(AssertionError):
121125
)
122126
"""
123127

124-
def __init__(self, message: str, report: "LiveCheckReport") -> None:
128+
def __init__(self, message: str, report: "LiveCheckReport", stdout: str = "", stderr: str = "") -> None:
125129
super().__init__(message)
126130
self.report = report
131+
self.stdout = stdout
132+
self.stderr = stderr
127133

128134

129135
class LiveCheckReport:
@@ -151,6 +157,20 @@ class LiveCheckReport:
151157
def __init__(self, report: dict[str, Any]) -> None:
152158
self._report = report
153159

160+
def to_dict(self) -> dict[str, Any]:
161+
"""Return a deep copy of the full report for inspection or JSON serialization."""
162+
return deepcopy(self._report)
163+
164+
@property
165+
def samples(self) -> list[dict[str, Any]]:
166+
"""Return a deep copy of the samples, or an empty list if absent."""
167+
return deepcopy(self._report.get("samples", []))
168+
169+
@property
170+
def statistics(self) -> dict[str, Any]:
171+
"""Return a deep copy of the statistics, or an empty dict if absent."""
172+
return deepcopy(self._report.get("statistics", {}))
173+
154174
@functools.cached_property
155175
def violations(self) -> list[dict[str, Any]]:
156176
"""Deduplicated list of semconv violations found in the report.
@@ -237,6 +257,8 @@ def __init__(
237257
admin_port: int = 0,
238258
extra_args: Sequence[str] | None = None,
239259
startup_timeout: float = 30,
260+
config: str | None = None,
261+
advice_data: str | None = None,
240262
):
241263
"""Build the ``weaver registry live-check`` command.
242264
@@ -245,6 +267,11 @@ def __init__(
245267
callers pass additional weaver options — for example ``["--quiet"]``
246268
or ``["--skip-policies"]`` — without subclassing.
247269
270+
``config`` selects a Weaver TOML configuration file. Managed command
271+
flags take precedence over values in that file.
272+
``advice_data`` is passed to Weaver's ``--advice-data`` option to load
273+
JSON/YAML data for advice policies. Existing local paths are made
274+
absolute; other values (URLs, globs) are passed unchanged.
248275
``startup_timeout`` controls how long to wait for the health endpoint,
249276
in seconds. It defaults to 30 seconds.
250277
"""
@@ -265,6 +292,8 @@ def __init__(
265292
self._process: subprocess.Popen[bytes] | None = None
266293
self._stdout_path: str | None = None
267294
self._stderr_path: str | None = None
295+
self._stdout = ""
296+
self._stderr = ""
268297

269298
command = [
270299
weaver_bin,
@@ -280,6 +309,13 @@ def __init__(
280309
if policies_dir:
281310
command += ["--advice-policies", os.path.abspath(policies_dir)]
282311

312+
if config is not None:
313+
command += ["--config", os.path.abspath(config)]
314+
if advice_data is not None:
315+
if os.path.exists(advice_data):
316+
advice_data = os.path.abspath(advice_data)
317+
command += ["--advice-data", advice_data]
318+
283319
if registry is None:
284320
if schema_version is None:
285321
schema_version = list(Schemas)[-1].value.rsplit("/", 1)[-1]
@@ -443,8 +479,33 @@ def end_and_check(self, timeout: int = 30) -> "LiveCheckReport":
443479
raise LiveCheckError(
444480
f"Semconv violations found:\n{_format_violations(report.violations)}",
445481
report,
482+
stdout=self.stdout,
483+
stderr=self.stderr,
446484
)
447485

486+
@staticmethod
487+
def _read_output(path: str | None, cached: str) -> str:
488+
if path is None:
489+
return cached
490+
try:
491+
with open(path, "rb") as fp:
492+
return fp.read().decode(errors="replace")
493+
except OSError as exc:
494+
logger.debug("Could not read weaver output from %s: %s", path, exc)
495+
return cached
496+
497+
@property
498+
def stdout(self) -> str:
499+
"""Captured stdout so far, retained after close. Reading does not stop Weaver."""
500+
self._stdout = self._read_output(self._stdout_path, self._stdout)
501+
return self._stdout
502+
503+
@property
504+
def stderr(self) -> str:
505+
"""Captured stderr so far, retained after close. Reading does not stop Weaver."""
506+
self._stderr = self._read_output(self._stderr_path, self._stderr)
507+
return self._stderr
508+
448509
def _read_weaver_logs(self) -> str | None:
449510
if self._process is None:
450511
return None
@@ -456,13 +517,7 @@ def _read_weaver_logs(self) -> str | None:
456517
except subprocess.TimeoutExpired:
457518
pass
458519

459-
def _read(path: str | None) -> str:
460-
if path is None or not os.path.exists(path):
461-
return ""
462-
with open(path, "rb") as fp:
463-
return fp.read().decode(errors="replace")
464-
465-
return f"{_read(self._stdout_path)}\n{_read(self._stderr_path)}"
520+
return f"{self.stdout}\n{self.stderr}"
466521
except Exception as exc: # pylint: disable=broad-except
467522
logger.error("Could not get weaver logs: %s", exc)
468523
return None
@@ -488,6 +543,12 @@ def close(self) -> None:
488543
self._process.wait(timeout=5)
489544
except subprocess.TimeoutExpired:
490545
self._process.kill()
546+
try:
547+
self._process.wait(timeout=5)
548+
except subprocess.TimeoutExpired:
549+
logger.debug("Weaver did not exit after kill; continuing output cleanup")
550+
self._stdout = self.stdout
551+
self._stderr = self.stderr
491552
for path in (self._stdout_path, self._stderr_path):
492553
if path is not None:
493554
try:

‎tests/opentelemetry-test-utils/tests/test_weaver_live_check.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
https://github.com/open-telemetry/weaver/releases
88
"""
99

10+
import json
1011
import os
1112
import shutil
13+
import tempfile
1214
import unittest
15+
from pathlib import Path
1316

1417
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
1518
from opentelemetry.sdk.trace import TracerProvider
@@ -50,6 +53,41 @@ def _make_provider(otlp_endpoint: str) -> TracerProvider:
5053
"weaver binary not found on PATH — install from https://github.com/open-telemetry/weaver/releases",
5154
)
5255
class TestSDKInitLiveCheck(unittest.TestCase):
56+
def test_config_advice_data_and_retained_diagnostics(self):
57+
with tempfile.TemporaryDirectory() as directory:
58+
root = Path(directory)
59+
policies = root / "policies"
60+
policies.mkdir()
61+
(policies / "test.rego").write_text(
62+
"package live_check_advice\nimport rego.v1\n"
63+
"deny contains result if {\n"
64+
' input.sample.attribute.name == "never.use.this.attribute"\n'
65+
' result := {"type": "advice", "advice_type": "data_check",\n'
66+
' "advice_level": "violation", "message": data.settings.message}\n'
67+
"}\n",
68+
encoding="utf-8",
69+
)
70+
data = root / "data"
71+
data.mkdir()
72+
(data / "settings.json").write_text('{"message": "message from advice data"}', encoding="utf-8")
73+
config = root / "weaver.toml"
74+
config.write_text(f"[live-check]\nadvice_policies = {json.dumps(str(policies))}\n", encoding="utf-8")
75+
with WeaverLiveCheck(registry=_REGISTRY_DIR, config=str(config), advice_data=str(data)) as weaver:
76+
provider = _make_provider(weaver.otlp_endpoint)
77+
try:
78+
with provider.get_tracer("test-tracer").start_as_current_span("test-span") as span:
79+
span.set_attribute("never.use.this.attribute", "bad value")
80+
provider.force_flush()
81+
with self.assertRaisesRegex(LiveCheckError, "message from advice data") as caught:
82+
weaver.end_and_check()
83+
finally:
84+
provider.shutdown()
85+
self.assertTrue(weaver.stderr)
86+
self.assertEqual(caught.exception.stderr, weaver.stderr)
87+
self.assertEqual(caught.exception.stdout, weaver.stdout)
88+
self.assertTrue(caught.exception.report.samples)
89+
self.assertTrue(caught.exception.report.statistics)
90+
5391
def test_end_and_check_no_violations(self):
5492
"""end_and_check() returns a LiveCheckReport with no violations on conformant telemetry."""
5593
with WeaverLiveCheck(registry=_REGISTRY_DIR) as weaver:
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# pylint: disable=protected-access
5+
6+
import json
7+
import os
8+
import subprocess
9+
import tempfile
10+
import unittest
11+
from contextlib import ExitStack
12+
from pathlib import Path
13+
from unittest.mock import Mock, patch
14+
15+
from opentelemetry.test.weaver_live_check import LiveCheckError, LiveCheckReport, WeaverLiveCheck
16+
17+
18+
class TestReportAccess(unittest.TestCase):
19+
def test_report_can_be_serialized_without_losing_fields(self):
20+
raw = {"samples": [{"span": {"name": "test"}}], "statistics": {"count": 1}, "extra": [1]}
21+
report = LiveCheckReport(raw)
22+
self.assertEqual(json.loads(json.dumps(report.to_dict())), raw)
23+
self.assertEqual(report.samples, raw["samples"])
24+
self.assertEqual(report.statistics, raw["statistics"])
25+
report.to_dict()["extra"].append(2)
26+
report.samples[0]["span"]["name"] = "changed"
27+
report.statistics["count"] = 2
28+
self.assertEqual(report["extra"], [1])
29+
self.assertEqual(report.samples[0]["span"]["name"], "test")
30+
self.assertEqual(report.statistics["count"], 1)
31+
32+
def test_missing_report_sections(self):
33+
report = LiveCheckReport({})
34+
self.assertEqual(report.samples, [])
35+
self.assertEqual(report.statistics, {})
36+
37+
38+
class TestWeaverOptions(unittest.TestCase):
39+
@patch("opentelemetry.test.weaver_live_check.shutil.which", return_value="weaver")
40+
def test_named_options(self, _which):
41+
for data in ("data/*.json", "data with spaces", "https://example.com/data.tar.gz[policies]"):
42+
with self.subTest(advice_data=data):
43+
weaver = WeaverLiveCheck(
44+
registry="registry",
45+
otlp_port=4317,
46+
admin_port=4320,
47+
config="config with spaces.toml",
48+
advice_data=data,
49+
extra_args=["--quiet"],
50+
)
51+
command = weaver._command
52+
self.assertEqual(command[command.index("--config") + 1], os.path.abspath("config with spaces.toml"))
53+
self.assertEqual(command[command.index("--advice-data") + 1], data)
54+
self.assertEqual(command[-1], "--quiet")
55+
56+
@patch("opentelemetry.test.weaver_live_check.shutil.which", return_value="weaver")
57+
def test_local_advice_data_is_absolute(self, _which):
58+
with tempfile.TemporaryDirectory() as directory:
59+
Path(directory, "data.json").write_text("{}", encoding="utf-8")
60+
cwd = os.getcwd()
61+
os.chdir(directory)
62+
try:
63+
weaver = WeaverLiveCheck(registry="registry", otlp_port=4317, admin_port=4320, advice_data="data.json")
64+
expected = os.path.abspath("data.json")
65+
finally:
66+
os.chdir(cwd)
67+
command = weaver._command
68+
self.assertEqual(command[command.index("--advice-data") + 1], expected)
69+
70+
@patch("opentelemetry.test.weaver_live_check.shutil.which", return_value="weaver")
71+
def test_optional_flags_are_omitted(self, _which):
72+
weaver = WeaverLiveCheck(registry="registry", otlp_port=4317, admin_port=4320)
73+
self.assertNotIn("--config", weaver._command)
74+
self.assertNotIn("--advice-data", weaver._command)
75+
76+
77+
class TestWeaverOutput(unittest.TestCase):
78+
def setUp(self):
79+
stack = ExitStack()
80+
self.addCleanup(stack.close)
81+
stack.enter_context(patch("opentelemetry.test.weaver_live_check.shutil.which", return_value="weaver"))
82+
directory = Path(stack.enter_context(tempfile.TemporaryDirectory()))
83+
self.stdout_path = directory / "stdout.log"
84+
self.stderr_path = directory / "stderr.log"
85+
self.stdout_path.write_bytes(b"output\xff\n")
86+
self.stderr_path.write_bytes(b"diagnostic\n")
87+
self.weaver = WeaverLiveCheck(registry="registry", otlp_port=4317, admin_port=4320)
88+
self.weaver._stdout_path = str(self.stdout_path)
89+
self.weaver._stderr_path = str(self.stderr_path)
90+
self.process = Mock()
91+
self.process.poll.return_value = None
92+
self.weaver._process = self.process
93+
self.addCleanup(self.weaver.close)
94+
95+
def test_reading_output_does_not_stop_process(self):
96+
self.assertEqual(self.weaver.stdout, "output\ufffd\n")
97+
self.assertEqual(self.weaver.stderr, "diagnostic\n")
98+
self.process.assert_not_called()
99+
self.assertEqual(self.process.mock_calls, [])
100+
self.stderr_path.write_bytes(b"more diagnostics\n")
101+
self.assertEqual(self.weaver.stderr, "more diagnostics\n")
102+
103+
def test_close_retains_output_and_deletes_files(self):
104+
self.weaver.close()
105+
self.weaver.close()
106+
self.assertFalse(self.stdout_path.exists())
107+
self.assertFalse(self.stderr_path.exists())
108+
self.assertEqual(self.weaver.stdout, "output\ufffd\n")
109+
self.assertEqual(self.weaver.stderr, "diagnostic\n")
110+
111+
def test_forced_exit_retains_output(self):
112+
self.process.wait.side_effect = [subprocess.TimeoutExpired("weaver", 5), 0]
113+
self.process.poll.side_effect = [None, 0]
114+
self.weaver.close()
115+
self.process.kill.assert_called_once_with()
116+
self.assertEqual(self.process.wait.call_count, 2)
117+
self.assertEqual(self.weaver.stderr, "diagnostic\n")
118+
119+
def test_output_preserves_windows_line_endings(self):
120+
self.stderr_path.write_bytes(b"diagnostic\r\n")
121+
self.weaver.close()
122+
self.assertEqual(self.weaver.stderr, "diagnostic\r\n")
123+
124+
def test_post_kill_timeout_preserves_original_error_and_output(self):
125+
self.process.wait.side_effect = subprocess.TimeoutExpired("weaver", 5)
126+
self.process.poll.side_effect = [None, 0]
127+
with patch.object(self.weaver, "start", return_value=self.weaver):
128+
with self.assertRaisesRegex(RuntimeError, "original failure"):
129+
with self.weaver:
130+
raise RuntimeError("original failure")
131+
self.process.kill.assert_called_once_with()
132+
self.assertEqual(self.process.wait.call_count, 2)
133+
self.assertFalse(self.stdout_path.exists())
134+
self.assertFalse(self.stderr_path.exists())
135+
self.assertEqual(self.weaver.stdout, "output\ufffd\n")
136+
self.assertEqual(self.weaver.stderr, "diagnostic\n")
137+
138+
def test_error_keeps_output_after_close(self):
139+
report = LiveCheckReport({})
140+
with patch.object(self.weaver, "_do_stop", return_value=(report, 1)):
141+
with self.assertRaises(LiveCheckError) as caught:
142+
self.weaver.end_and_check()
143+
self.weaver.close()
144+
self.assertEqual(caught.exception.stdout, "output\ufffd\n")
145+
self.assertEqual(caught.exception.stderr, "diagnostic\n")
146+
147+
def test_missing_output_file_preserves_last_read(self):
148+
self.assertEqual(self.weaver.stderr, "diagnostic\n")
149+
self.stderr_path.unlink()
150+
self.assertEqual(self.weaver.stderr, "diagnostic\n")
151+
152+
def test_error_constructor_remains_compatible(self):
153+
error = LiveCheckError("violation", LiveCheckReport({}))
154+
self.assertEqual(error.stdout, "")
155+
self.assertEqual(error.stderr, "")

0 commit comments

Comments
 (0)