Skip to content
Open
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: 7 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,10 @@ outcome, exit code, and duration. Raw argv, configuration values, filesystem
paths, and secrets are never attached. A missing API package, invalid provider,
or failing exporter is logged at debug level and treated as a no-op; it cannot
change the command's exit status or cleanup behavior.

The span status is `OK` for a successful invocation and `ERROR` for every
non-success outcome. Unexpected exceptions are recorded on failed spans;
successful `SystemExit(0)` and Click exit-control flow are not recorded as
exceptions. Interrupts and other non-success outcomes are therefore visible as
errors while retaining the existing `base_cli.outcome` attribute for detailed
dashboard filtering.
3 changes: 3 additions & 0 deletions lib/python/base_cli/_app_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,7 @@ def wrapper(**kwargs: Any) -> Any:
recorder: RunRecorder | None = None
telemetry_session: TelemetrySession | None = None
outcome = outcome_from_exit_code(ExitCode.SUCCESS)
exception: BaseException | None = None
invocation_argv: list[str] = []
redaction_plan = self._redaction_plan
if redaction_plan is None:
Expand Down Expand Up @@ -1074,6 +1075,7 @@ def wrapper(**kwargs: Any) -> Any:
outcome = outcome_from_exit_code(exit_code)
return result
except BaseException as exc:
exception = exc
if context is not None:
outcome = outcome_from_exception(click, exc)
_record_lifecycle_diagnostic(context, outcome)
Expand Down Expand Up @@ -1111,6 +1113,7 @@ def wrapper(**kwargs: Any) -> Any:
context,
outcome,
ended_monotonic_ns=ended_monotonic_ns,
exception=exception,
)
_finish_run_recorder(
recorder,
Expand Down
9 changes: 8 additions & 1 deletion lib/python/base_cli/_attach.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def __init__(
self.context: Context[Any, Any, Any] | None = None
self.invocation: _AttachedInvocation | None = None
self.telemetry_session: TelemetrySession | None = None
self.exception: BaseException | None = None
self.context_token: Any = None
self.invocation_token: Any = None
self.original_click_exit: Callable[..., Any] | None = None
Expand Down Expand Up @@ -164,11 +165,16 @@ def record_result(self, _result: Any) -> None:
state.attached_completion = True

def record_exception(self, exc: BaseException) -> None:
outcome = outcome_from_exception(self.click, exc)
# Click represents a successful ``Context.exit(0)`` as an exception so
# it can unwind the context stack. It is control flow, not a failed
# command, and must not be exported as a span exception.
self.exception = None if str(outcome.status) == "ok" else exc
state = _INVOCATION_STATE.get()
if state is not None and state.owner_app is self.attachment.app:
state.attached_completion = False
if self.context is not None:
self.outcome = outcome_from_exception(self.click, exc)
self.outcome = outcome
_record_lifecycle_diagnostic(self.context, self.outcome)

def __exit__(
Expand Down Expand Up @@ -239,6 +245,7 @@ def _finalize(self) -> None:
context,
self.outcome,
ended_monotonic_ns=ended_monotonic_ns,
exception=self.exception,
)
try:
context.cleanup()
Expand Down
17 changes: 17 additions & 0 deletions lib/python/base_cli/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def finish_telemetry(
outcome: Any,
*,
ended_monotonic_ns: int | None = None,
exception: BaseException | None = None,
) -> None:
"""Finish a lifecycle span without allowing exporters to affect teardown."""

Expand All @@ -148,6 +149,9 @@ def finish_telemetry(
}
for key, value in attributes.items():
_safe_span_call(session.span, "set_attribute", key, value)
if exception is not None and str(getattr(outcome, "status", "error")) != "ok":
_safe_span_call(session.span, "record_exception", exception)
_set_span_status(session.span, outcome)
_safe_span_call(
session.span,
"add_event",
Expand All @@ -173,6 +177,19 @@ def _start_attributes(context: Any) -> dict[str, Any]:
}


def _set_span_status(span: Any, outcome: Any) -> None:
"""Set an OpenTelemetry status while remaining compatible with test spans."""

is_success = str(getattr(outcome, "status", "error")) == "ok"
try:
from opentelemetry.trace import Status, StatusCode

status: Any = Status(StatusCode.OK if is_success else StatusCode.ERROR)
except Exception: # pragma: no cover - optional dependency boundary
status = "ok" if is_success else "error"
_safe_span_call(span, "set_status", status)


def _safe_span_call(span: Any, method: str, *args: Any, **kwargs: Any) -> None:
try:
callback = getattr(span, method, None)
Expand Down
30 changes: 30 additions & 0 deletions tests/test_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ def __init__(self) -> None:
self.attributes: dict[str, object] = {}
self.events: list[tuple[str, dict[str, object]]] = []
self.ended = False
self.status: object | None = None
self.exceptions: list[BaseException] = []

def set_attribute(self, key: str, value: object) -> None:
self.attributes[key] = value
Expand All @@ -27,6 +29,12 @@ def add_event(self, name: str, *, attributes: dict[str, object]) -> None:
def end(self) -> None:
self.ended = True

def set_status(self, status: object) -> None:
self.status = status

def record_exception(self, exception: BaseException) -> None:
self.exceptions.append(exception)


class _Tracer:
def __init__(self) -> None:
Expand Down Expand Up @@ -118,6 +126,28 @@ def main(ctx: base_cli.Context) -> None:
["base_cli.run.started", "base_cli.run.finished"],
)
self.assertIn("base_cli.duration_ms", tracer.span.attributes)
self.assertEqual(tracer.span.exceptions, [])
self.assertIsNotNone(tracer.span.status)

def test_telemetry_marks_failures_and_records_exception(self) -> None:
tracer = _Tracer()
app = base_cli.App(
name="telemetry-error",
log_to_file=False,
telemetry=base_cli.TelemetryOptions(tracer=tracer),
)

@app.command()
def main(ctx: base_cli.Context) -> None:
del ctx
raise RuntimeError("boom")

with tempfile.TemporaryDirectory() as tmpdir:
result = invoke(app, [], home=Path(tmpdir))

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.

Test gap: this new test only exercises the failure path (asserts one recorded exception on a raised RuntimeError) and never asserts that a successful invocation leaves tracer.span.exceptions empty. So this PR's own test suite can't catch the unconditional-record_exception bug flagged on integrations.py/_attach.py — the exact regression this PR introduces for ctx.exit(0)/SystemExit(0) exits would pass this test unchanged, since nothing here asserts zero exceptions on a success run.

self.assertNotEqual(result.exit_code, 0)
self.assertEqual(len(tracer.span.exceptions), 1)
self.assertIsNotNone(tracer.span.status)

def test_missing_or_broken_telemetry_never_changes_completion(self) -> None:
app = base_cli.App(
Expand Down
Loading