Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/optional-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ rich = [
]
telemetry = [
"opentelemetry-api>=1.24,<2",
"opentelemetry-sdk>=1.24,<2",
]

[project.scripts]
Expand Down
46 changes: 42 additions & 4 deletions src/base_cli_demo/telemetry_scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -23,15 +44,32 @@ 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)


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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: unguarded post-run flush/read can crash the CLI after the command already succeeded

TRACER_PROVIDER.force_flush() and SPAN_EXPORTER.get_finished_spans() here have no try/except, unlike every other OTel touchpoint base_cli owns (start_telemetry/finish_telemetry/_safe_span_call all wrap calls in except BaseException, per the framework's "broken plugin can't change command completion" contract). If either call raises for any reason in a real/degraded OTel install, the exception propagates out of main() after base_cli.run_app(command) has already returned a successful exit_code — turning a successful command into an unhandled traceback and non-zero process exit. Consider wrapping this block the same way base_cli wraps its own span calls.



if __name__ == "__main__":
Expand Down
81 changes: 81 additions & 0 deletions tests/test_optional_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
29 changes: 29 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading