diff --git a/docs/optional-integrations.md b/docs/optional-integrations.md index 853ced1..db73071 100644 --- a/docs/optional-integrations.md +++ b/docs/optional-integrations.md @@ -41,7 +41,7 @@ $ northstar-rich --quiet status --format json ## OpenTelemetry -Install the optional API package to enable the lifecycle integration: +Install the optional API and SDK packages to record the lifecycle integration: ```console $ python -m pip install ".[telemetry]" @@ -51,8 +51,9 @@ telemetry=enabled Without the extra, the command reports `telemetry=unavailable (install [telemetry])` and still exits successfully. With the extra, Base-CLI owns the -`base_cli.run` lifecycle span and its bounded safe attributes; the scenario -does not attach argv, configuration, paths, or secrets. +`base_cli.run` lifecycle span and its bounded safe attributes. The scenario +records the span in an in-memory exporter and prints its name/status; it does +not attach argv, configuration, paths, or secrets. The focused tests run in both modes: the normal CI job exercises the minimal fallbacks, while the optional-integration CI job installs all three extras and diff --git a/pyproject.toml b/pyproject.toml index 87c6b35..af71a17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ rich = [ ] telemetry = [ "opentelemetry-api>=1.24,<2", + "opentelemetry-sdk>=1.24,<2", ] [project.scripts] diff --git a/src/base_cli_demo/telemetry_scenario.py b/src/base_cli_demo/telemetry_scenario.py index de8cb68..f9f7519 100644 --- a/src/base_cli_demo/telemetry_scenario.py +++ b/src/base_cli_demo/telemetry_scenario.py @@ -2,12 +2,33 @@ from __future__ import annotations -import importlib.util +from typing import Any import base_cli import click -TELEMETRY_AVAILABLE = importlib.util.find_spec("opentelemetry") is not None +def _configure_telemetry() -> tuple[Any | None, Any | None]: + """Create the demo SDK objects without making import failures fatal.""" + + try: + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + return exporter, provider + except BaseException: + # Optional integration setup must not change the consumer's normal + # command behavior when the plugin is missing or broken. + return None, None + + +SPAN_EXPORTER, TRACER_PROVIDER = _configure_telemetry() +TELEMETRY_AVAILABLE = SPAN_EXPORTER is not None and TRACER_PROVIDER is not None @click.command(name="northstar-telemetry", help="Run the optional telemetry scenario.") @@ -23,7 +44,11 @@ def status() -> None: app = base_cli.App( name="northstar-telemetry", log_to_file=False, - telemetry=base_cli.TelemetryOptions() if TELEMETRY_AVAILABLE else None, + telemetry=( + base_cli.TelemetryOptions(tracer_provider=TRACER_PROVIDER) + if TELEMETRY_AVAILABLE + else None + ), ) command = app.attach(status) @@ -31,7 +56,20 @@ def status() -> None: def main() -> int: """Run telemetry without making its SDK a core dependency.""" - return base_cli.run_app(command) + exit_code = base_cli.run_app(command) + if SPAN_EXPORTER is not None and TRACER_PROVIDER is not None: + try: + TRACER_PROVIDER.force_flush() + spans = SPAN_EXPORTER.get_finished_spans() + click.echo(f"recorded_spans={len(spans)}") + for span in spans: + click.echo(f"span={span.name} status={span.status.status_code.name}") + except BaseException: + # Exporter teardown is best-effort after the command outcome is + # already known; it must not turn a successful invocation into a + # failed CLI run. + pass + return exit_code if __name__ == "__main__": diff --git a/tests/test_optional_scenarios.py b/tests/test_optional_scenarios.py index 64247de..f23849f 100644 --- a/tests/test_optional_scenarios.py +++ b/tests/test_optional_scenarios.py @@ -3,13 +3,25 @@ import importlib.util import json import os +import pty +import select import subprocess import sys +import time from pathlib import Path import pytest +def module_available(name: str) -> bool: + """Treat a missing parent package as an unavailable optional module.""" + + try: + return importlib.util.find_spec(name) is not None + except ModuleNotFoundError: + return False + + def run_scenario( module: str, args: list[str], home: Path ) -> subprocess.CompletedProcess[str]: @@ -91,3 +103,72 @@ def test_telemetry_reports_the_optional_state_without_affecting_exit_status( assert result.returncode == 0, result.stderr assert result.stdout.startswith("telemetry=") + + +def test_telemetry_sdk_records_the_base_cli_lifecycle_span(tmp_path: Path) -> None: + if not module_available("opentelemetry.sdk"): + pytest.skip("OpenTelemetry SDK is installed by the optional telemetry extra") + + result = run_scenario("base_cli_demo.telemetry_scenario", ["--quiet"], tmp_path) + + assert result.returncode == 0, result.stderr + assert "recorded_spans=1" in result.stdout + assert "span=base_cli.run" in result.stdout + assert "status=UNSET" in result.stdout + + +def test_rich_human_renderer_runs_on_a_real_terminal(tmp_path: Path) -> None: + if os.name != "posix" or importlib.util.find_spec("rich") is None: + pytest.skip("Rich TTY integration requires POSIX and the Rich extra") + + master_fd, slave_fd = pty.openpty() + environment = os.environ.copy() + environment.update( + { + "HOME": str(tmp_path / "home"), + "BASE_CLI_CACHE_DIR": str(tmp_path / "cache"), + "USERPROFILE": str(tmp_path / "home"), + "TERM": "xterm-256color", + "COLUMNS": "80", + } + ) + process = subprocess.Popen( + [sys.executable, "-m", "base_cli_demo.rich_scenario", "--quiet", "status"], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=tmp_path, + env=environment, + close_fds=True, + ) + os.close(slave_fd) + output = bytearray() + deadline = time.monotonic() + 20 + try: + while process.poll() is None: + if time.monotonic() >= deadline: + process.kill() + pytest.fail("Rich TTY command did not finish within 20 seconds") + readable, _, _ = select.select([master_fd], [], [], 0.1) + if readable: + try: + output.extend(os.read(master_fd, 4096)) + except OSError: + break + process.wait(timeout=20) + while select.select([master_fd], [], [], 0.1)[0]: + try: + chunk = os.read(master_fd, 4096) + except OSError: + break + if not chunk: + break + output.extend(chunk) + finally: + os.close(master_fd) + + rendered = output.decode("utf-8", errors="replace") + assert process.returncode == 0, rendered + assert "orders-api" in rendered + assert "degraded" in rendered + assert "─" in rendered diff --git a/uv.lock b/uv.lock index de52faf..d30bf75 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,7 @@ rich = [ ] telemetry = [ { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, ] typer = [ { name = "typer" }, @@ -63,6 +64,7 @@ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.2,<2" }, { name = "click", specifier = ">=8.1,<9" }, { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.24,<2" }, + { name = "opentelemetry-sdk", marker = "extra == 'telemetry'", specifier = ">=1.24,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8,<9" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.7,<15" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=5,<7" }, @@ -579,6 +581,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + [[package]] name = "packaging" version = "26.3"