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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ All subclasses carry the same attributes, so existing handlers can migrate by ca

`python -m codetracer_python_recorder` returns:

- `0` when tracing and the target script succeed.
- The script's own exit code when it calls `sys.exit()`.
- `1` when a `RecorderError` bubbles out of startup or shutdown.
- `2` when the CLI arguments are incomplete.
- `0` when the recorder finishes cleanly, even if the traced script exits non-zero. The script's status is still recorded in `trace_metadata.json`, and a warning on stderr highlights the suppressed status.
- `1` when a `RecorderError` bubbles out of startup or shutdown (policy failures, `require_trace`, flush/stop issues).
- `2` when the CLI arguments are incomplete or invalid.

Pass `--codetracer-json-errors` (or set the policy via `configure_policy(json_errors=True)`) to stream a one-line JSON trailer on stderr. The payload includes `run_id`, `trace_id`, `error_code`, `error_kind`, `message`, and the `context` map so downstream tooling can log failures without scraping text.
Opt into mirroring the script's exit code with `--propagate-script-exit` (or `CODETRACER_PROPAGATE_SCRIPT_EXIT=true`). Use `--no-propagate-script-exit` to force suppression, even if the environment enables mirroring.

Pass `--json-errors` (or set the policy via `configure_policy(json_errors=True)`) to stream a one-line JSON trailer on stderr. The payload includes `run_id`, `trace_id`, `error_code`, `error_kind`, `message`, and the `context` map so downstream tooling can log failures without scraping text.

### IO capture configuration

Expand Down
1 change: 1 addition & 0 deletions codetracer-python-recorder/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)

### Changed
- Module-level call events now prefer the frame's `__name__`, fall back to filter hints, `sys.path`, and package markers, and no longer depend on the legacy resolver/cache. The globals-derived naming flag now defaults to enabled so direct scripts record `<__main__>` while package imports emit `<pkg.mod>`, with CLI and environment overrides available for the legacy resolver.
- The CLI now exits with `0` when recording succeeds regardless of the traced script’s status, records a warning when suppressing non-zero script exits, and exposes `--propagate-script-exit` / `CODETRACER_PROPAGATE_SCRIPT_EXIT` / `configure_policy(propagate_script_exit=True)` to restore passthrough semantics.

## [0.2.0] - 2025-10-17
### Added
Expand Down
45 changes: 41 additions & 4 deletions codetracer-python-recorder/codetracer_python_recorder/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from pathlib import Path
from typing import Iterable, Sequence

from . import flush, start, stop
from . import flush, policy_snapshot, start, stop
from .auto_start import ENV_TRACE_FILTER
from .formats import DEFAULT_FORMAT, SUPPORTED_FORMATS, normalize_format

Expand Down Expand Up @@ -129,6 +129,15 @@ def _parse_args(argv: Sequence[str]) -> RecorderCLIConfig:
"Use '--no-module-name-from-globals' to fall back to the legacy resolver."
),
)
parser.add_argument(
"--propagate-script-exit",
action=argparse.BooleanOptionalAction,
default=None,
help=(
"Mirror the traced script's exit status when the recorder succeeds (default: disabled). "
"Use '--no-propagate-script-exit' to force a zero exit status."
),
)

known, remainder = parser.parse_known_args(argv)
pending: list[str] = list(remainder)
Expand Down Expand Up @@ -192,6 +201,8 @@ def _parse_args(argv: Sequence[str]) -> RecorderCLIConfig:
parser.error(f"unsupported io-capture mode '{other}'")
if known.module_name_from_globals is not None:
policy["module_name_from_globals"] = known.module_name_from_globals
if known.propagate_script_exit is not None:
policy["propagate_script_exit"] = known.propagate_script_exit

return RecorderCLIConfig(
trace_dir=trace_dir,
Expand Down Expand Up @@ -286,7 +297,11 @@ def main(argv: Iterable[str] | None = None) -> int:
sys.argv = old_argv
return 1

snapshot = policy_snapshot()
propagate_script_exit = bool(snapshot.get("propagate_script_exit"))

exit_code: int | None = None
recorder_failed = False
try:
try:
runpy.run_path(str(script_path), run_name="__main__")
Expand All @@ -297,13 +312,35 @@ def main(argv: Iterable[str] | None = None) -> int:
finally:
try:
flush()
except Exception as exc:
recorder_failed = True
sys.stderr.write(f"Failed to flush Codetracer session: {exc}\n")
finally:
stop(exit_code=exit_code)
sys.argv = old_argv
try:
stop(exit_code=exit_code)
except Exception as exc:
recorder_failed = True
sys.stderr.write(f"Failed to stop Codetracer session: {exc}\n")
finally:
sys.argv = old_argv

_serialise_metadata(trace_dir, script=script_path)

return exit_code if exit_code is not None else 0
script_exit_code = exit_code if exit_code is not None else 0

if recorder_failed:
return 1

if propagate_script_exit:
return script_exit_code

if script_exit_code != 0:
sys.stderr.write(
f"Script exited with status {script_exit_code}; returning 0. "
"Use '--propagate-script-exit' to mirror the script exit code.\n"
)

return 0


__all__ = ("main", "RecorderCLIConfig")
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ def start(
policy:
Optional mapping of runtime policy overrides forwarded to
:func:`configure_policy` before tracing begins. Keys match the policy
keyword arguments (``on_recorder_error``, ``require_trace``, etc.).
keyword arguments (``on_recorder_error``, ``require_trace``,
``propagate_script_exit``, etc.).
apply_env_policy:
When ``True`` (default), refresh policy settings from environment
variables via :func:`configure_policy_from_env` prior to applying
Expand Down
8 changes: 7 additions & 1 deletion codetracer-python-recorder/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod model;
pub use env::{
configure_policy_from_env, ENV_CAPTURE_IO, ENV_JSON_ERRORS, ENV_KEEP_PARTIAL_TRACE,
ENV_LOG_FILE, ENV_LOG_LEVEL, ENV_MODULE_NAME_FROM_GLOBALS, ENV_ON_RECORDER_ERROR,
ENV_REQUIRE_TRACE,
ENV_PROPAGATE_SCRIPT_EXIT, ENV_REQUIRE_TRACE,
};
#[allow(unused_imports)]
pub use ffi::{configure_policy_py, py_configure_policy_from_env, py_policy_snapshot};
Expand Down Expand Up @@ -43,6 +43,7 @@ mod tests {
assert!(snap.io_capture.line_proxies);
assert!(!snap.io_capture.fd_fallback);
assert!(snap.module_name_from_globals);
assert!(!snap.propagate_script_exit);
}

#[test]
Expand All @@ -58,6 +59,7 @@ mod tests {
update.io_capture_line_proxies = Some(true);
update.io_capture_fd_fallback = Some(true);
update.module_name_from_globals = Some(true);
update.propagate_script_exit = Some(true);

apply_policy_update(update);

Expand All @@ -71,6 +73,7 @@ mod tests {
assert!(snap.io_capture.line_proxies);
assert!(snap.io_capture.fd_fallback);
assert!(snap.module_name_from_globals);
assert!(snap.propagate_script_exit);
reset_policy();
}

Expand All @@ -86,6 +89,7 @@ mod tests {
std::env::set_var(ENV_JSON_ERRORS, "yes");
std::env::set_var(ENV_CAPTURE_IO, "proxies,fd");
std::env::set_var(ENV_MODULE_NAME_FROM_GLOBALS, "true");
std::env::set_var(ENV_PROPAGATE_SCRIPT_EXIT, "true");

configure_policy_from_env().expect("configure from env");

Expand All @@ -101,6 +105,7 @@ mod tests {
assert!(snap.io_capture.line_proxies);
assert!(snap.io_capture.fd_fallback);
assert!(snap.module_name_from_globals);
assert!(snap.propagate_script_exit);
reset_policy();
}

Expand Down Expand Up @@ -163,6 +168,7 @@ mod tests {
ENV_JSON_ERRORS,
ENV_CAPTURE_IO,
ENV_MODULE_NAME_FROM_GLOBALS,
ENV_PROPAGATE_SCRIPT_EXIT,
] {
std::env::remove_var(key);
}
Expand Down
11 changes: 11 additions & 0 deletions codetracer-python-recorder/src/policy/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub const ENV_JSON_ERRORS: &str = "CODETRACER_JSON_ERRORS";
pub const ENV_CAPTURE_IO: &str = "CODETRACER_CAPTURE_IO";
/// Environment variable toggling globals-based module name resolution.
pub const ENV_MODULE_NAME_FROM_GLOBALS: &str = "CODETRACER_MODULE_NAME_FROM_GLOBALS";
/// Environment variable toggling whether the recorder mirrors script exit codes.
pub const ENV_PROPAGATE_SCRIPT_EXIT: &str = "CODETRACER_PROPAGATE_SCRIPT_EXIT";

/// Load policy overrides from environment variables.
pub fn configure_policy_from_env() -> RecorderResult<()> {
Expand Down Expand Up @@ -66,6 +68,10 @@ pub fn configure_policy_from_env() -> RecorderResult<()> {
update.module_name_from_globals = Some(parse_bool(&value)?);
}

if let Ok(value) = env::var(ENV_PROPAGATE_SCRIPT_EXIT) {
update.propagate_script_exit = Some(parse_bool(&value)?);
}

apply_policy_update(update);
Ok(())
}
Expand Down Expand Up @@ -148,6 +154,7 @@ mod tests {
std::env::set_var(ENV_JSON_ERRORS, "yes");
std::env::set_var(ENV_CAPTURE_IO, "proxies,fd");
std::env::set_var(ENV_MODULE_NAME_FROM_GLOBALS, "true");
std::env::set_var(ENV_PROPAGATE_SCRIPT_EXIT, "true");

configure_policy_from_env().expect("configure from env");
let snap = policy_snapshot();
Expand All @@ -163,17 +170,20 @@ mod tests {
assert!(snap.io_capture.line_proxies);
assert!(snap.io_capture.fd_fallback);
assert!(snap.module_name_from_globals);
assert!(snap.propagate_script_exit);
}

#[test]
fn configure_policy_from_env_disables_module_name_from_globals() {
let _guard = EnvGuard;
reset_policy_for_tests();
std::env::set_var(ENV_MODULE_NAME_FROM_GLOBALS, "false");
std::env::set_var(ENV_PROPAGATE_SCRIPT_EXIT, "false");

configure_policy_from_env().expect("configure from env");
let snap = policy_snapshot();
assert!(!snap.module_name_from_globals);
assert!(!snap.propagate_script_exit);
}

#[test]
Expand Down Expand Up @@ -202,6 +212,7 @@ mod tests {
ENV_JSON_ERRORS,
ENV_CAPTURE_IO,
ENV_MODULE_NAME_FROM_GLOBALS,
ENV_PROPAGATE_SCRIPT_EXIT,
] {
std::env::remove_var(key);
}
Expand Down
18 changes: 17 additions & 1 deletion codetracer-python-recorder/src/policy/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::path::PathBuf;
use std::str::FromStr;

#[pyfunction(name = "configure_policy")]
#[pyo3(signature = (on_recorder_error=None, require_trace=None, keep_partial_trace=None, log_level=None, log_file=None, json_errors=None, io_capture_line_proxies=None, io_capture_fd_fallback=None, module_name_from_globals=None))]
#[pyo3(signature = (on_recorder_error=None, require_trace=None, keep_partial_trace=None, log_level=None, log_file=None, json_errors=None, io_capture_line_proxies=None, io_capture_fd_fallback=None, module_name_from_globals=None, propagate_script_exit=None))]
pub fn configure_policy_py(
on_recorder_error: Option<&str>,
require_trace: Option<bool>,
Expand All @@ -22,6 +22,7 @@ pub fn configure_policy_py(
io_capture_line_proxies: Option<bool>,
io_capture_fd_fallback: Option<bool>,
module_name_from_globals: Option<bool>,
propagate_script_exit: Option<bool>,
) -> PyResult<()> {
let mut update = PolicyUpdate::default();

Expand Down Expand Up @@ -69,6 +70,10 @@ pub fn configure_policy_py(
update.module_name_from_globals = Some(value);
}

if let Some(value) = propagate_script_exit {
update.propagate_script_exit = Some(value);
}

apply_policy_update(update);
Ok(())
}
Expand Down Expand Up @@ -106,6 +111,7 @@ pub fn py_policy_snapshot(py: Python<'_>) -> PyResult<PyObject> {
"module_name_from_globals",
snapshot.module_name_from_globals,
)?;
dict.set_item("propagate_script_exit", snapshot.propagate_script_exit)?;

let io_dict = PyDict::new(py);
io_dict.set_item("line_proxies", snapshot.io_capture.line_proxies)?;
Expand Down Expand Up @@ -133,6 +139,7 @@ mod tests {
Some(true),
Some(true),
Some(true),
Some(true),
)
.expect("configure policy via PyO3 facade");

Expand All @@ -152,6 +159,7 @@ mod tests {
assert!(snap.io_capture.line_proxies);
assert!(snap.io_capture.fd_fallback);
assert!(snap.module_name_from_globals);
assert!(snap.propagate_script_exit);
reset_policy_for_tests();
}

Expand All @@ -168,6 +176,7 @@ mod tests {
None,
None,
None,
None,
)
.expect_err("invalid variant should error");
// Ensure the error maps through map_recorder_error by checking the display text.
Expand Down Expand Up @@ -208,6 +217,7 @@ mod tests {
Some(false),
Some(false),
Some(false),
Some(true),
)
.expect("configure policy");

Expand All @@ -224,6 +234,11 @@ mod tests {
dict.contains("io_capture").expect("check io_capture key"),
"expected io_capture in snapshot"
);
assert!(
dict.contains("propagate_script_exit")
.expect("check propagate_script_exit key"),
"expected propagate_script_exit in snapshot"
);
});
reset_policy_for_tests();
}
Expand All @@ -241,6 +256,7 @@ mod tests {
super::super::env::ENV_JSON_ERRORS,
super::super::env::ENV_CAPTURE_IO,
super::super::env::ENV_MODULE_NAME_FROM_GLOBALS,
super::super::env::ENV_PROPAGATE_SCRIPT_EXIT,
] {
std::env::remove_var(key);
}
Expand Down
6 changes: 6 additions & 0 deletions codetracer-python-recorder/src/policy/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub struct RecorderPolicy {
pub json_errors: bool,
pub io_capture: IoCapturePolicy,
pub module_name_from_globals: bool,
pub propagate_script_exit: bool,
}

impl Default for RecorderPolicy {
Expand All @@ -85,6 +86,7 @@ impl Default for RecorderPolicy {
json_errors: false,
io_capture: IoCapturePolicy::default(),
module_name_from_globals: true,
propagate_script_exit: false,
}
}
}
Expand Down Expand Up @@ -128,6 +130,9 @@ impl RecorderPolicy {
if let Some(module_name_from_globals) = update.module_name_from_globals {
self.module_name_from_globals = module_name_from_globals;
}
if let Some(propagate_script_exit) = update.propagate_script_exit {
self.propagate_script_exit = propagate_script_exit;
}
}
}

Expand All @@ -150,6 +155,7 @@ pub(crate) struct PolicyUpdate {
pub(crate) io_capture_line_proxies: Option<bool>,
pub(crate) io_capture_fd_fallback: Option<bool>,
pub(crate) module_name_from_globals: Option<bool>,
pub(crate) propagate_script_exit: Option<bool>,
}

/// Snapshot the current policy.
Expand Down
Loading
Loading