Skip to content

Commit aa3ba72

Browse files
committed
WS4-WS5
codetracer-python-recorder/src/runtime/tracer/events.rs: codetracer-python-recorder/src/runtime/tracer/lifecycle.rs: codetracer-python-recorder/src/runtime/tracer/runtime_tracer.rs: codetracer-python-recorder/tests/python/test_exit_payloads.py: codetracer-python-recorder/tests/python/test_trace_balance.py: design-docs/toplevel-exit-and-trace-gating-implementation-plan.status.md: Signed-off-by: Tzanko Matev <[email protected]>
1 parent 91d8408 commit aa3ba72

File tree

6 files changed

+172
-5
lines changed

6 files changed

+172
-5
lines changed

codetracer-python-recorder/src/runtime/tracer/events.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,9 +454,12 @@ impl Tracer for RuntimeTracer {
454454

455455
self.emit_session_exit(py);
456456

457+
let exit_summary = self.exit_summary();
458+
457459
if self.lifecycle.encountered_failure() {
458460
if policy.keep_partial_trace {
459-
if let Err(err) = self.lifecycle.finalise(&mut self.writer, &self.filter) {
461+
if let Err(err) = self.lifecycle.finalise(&mut self.writer, &self.filter, &exit_summary)
462+
{
460463
with_error_code(ErrorCode::TraceIncomplete, || {
461464
log::warn!(
462465
"failed to finalise partial trace after disable: {}",
@@ -489,7 +492,7 @@ impl Tracer for RuntimeTracer {
489492
.require_trace_or_fail(&policy)
490493
.map_err(ffi::map_recorder_error)?;
491494
self.lifecycle
492-
.finalise(&mut self.writer, &self.filter)
495+
.finalise(&mut self.writer, &self.filter, &exit_summary)
493496
.map_err(ffi::map_recorder_error)?;
494497
self.function_ids.clear();
495498
self.module_names.clear();

codetracer-python-recorder/src/runtime/tracer/lifecycle.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use crate::runtime::activation::ActivationController;
66
use crate::runtime::io_capture::ScopedMuteIoCapture;
77
use crate::runtime::output_paths::TraceOutputPaths;
88
use crate::runtime::tracer::filtering::FilterCoordinator;
9+
use crate::runtime::tracer::runtime_tracer::ExitSummary;
10+
use log::debug;
911
use recorder_errors::{enverr, usage, ErrorCode, RecorderResult};
1012
use runtime_tracing::{NonStreamingTraceWriter, TraceWriter};
1113
use serde_json::{self, json};
@@ -105,12 +107,12 @@ impl LifecycleController {
105107
&mut self,
106108
writer: &mut NonStreamingTraceWriter,
107109
filter: &FilterCoordinator,
110+
exit_summary: &ExitSummary,
108111
) -> RecorderResult<()> {
109112
TraceWriter::finish_writing_trace_metadata(writer).map_err(|err| {
110113
enverr!(ErrorCode::Io, "failed to finalise trace metadata")
111114
.with_context("source", err.to_string())
112115
})?;
113-
self.append_filter_metadata(filter)?;
114116
TraceWriter::finish_writing_trace_paths(writer).map_err(|err| {
115117
enverr!(ErrorCode::Io, "failed to finalise trace paths")
116118
.with_context("source", err.to_string())
@@ -119,6 +121,9 @@ impl LifecycleController {
119121
enverr!(ErrorCode::Io, "failed to finalise trace events")
120122
.with_context("source", err.to_string())
121123
})?;
124+
debug!("[Lifecycle] writing exit metadata: code={:?}, label={:?}", exit_summary.code, exit_summary.label);
125+
self.append_filter_metadata(filter)?;
126+
self.append_exit_metadata(exit_summary)?;
122127
Ok(())
123128
}
124129

@@ -132,6 +137,49 @@ impl LifecycleController {
132137
self.encountered_failure = false;
133138
}
134139

140+
fn append_exit_metadata(&self, exit_summary: &ExitSummary) -> RecorderResult<()> {
141+
let Some(outputs) = &self.output_paths else {
142+
return Ok(());
143+
};
144+
145+
let path = outputs.metadata();
146+
let original = fs::read_to_string(path).map_err(|err| {
147+
enverr!(ErrorCode::Io, "failed to read trace metadata")
148+
.with_context("path", path.display().to_string())
149+
.with_context("source", err.to_string())
150+
})?;
151+
152+
let mut metadata: serde_json::Value = serde_json::from_str(&original).map_err(|err| {
153+
enverr!(ErrorCode::Io, "failed to parse trace metadata JSON")
154+
.with_context("path", path.display().to_string())
155+
.with_context("source", err.to_string())
156+
})?;
157+
158+
if let serde_json::Value::Object(ref mut obj) = metadata {
159+
let status = json!({
160+
"code": exit_summary.code,
161+
"label": exit_summary.label,
162+
});
163+
obj.insert("process_exit_status".to_string(), status);
164+
let serialised = serde_json::to_string(&metadata).map_err(|err| {
165+
enverr!(ErrorCode::Io, "failed to serialise trace metadata")
166+
.with_context("path", path.display().to_string())
167+
.with_context("source", err.to_string())
168+
})?;
169+
fs::write(path, serialised).map_err(|err| {
170+
enverr!(ErrorCode::Io, "failed to write trace metadata")
171+
.with_context("path", path.display().to_string())
172+
.with_context("source", err.to_string())
173+
})?;
174+
Ok(())
175+
} else {
176+
Err(
177+
enverr!(ErrorCode::Io, "trace metadata must be a JSON object")
178+
.with_context("path", path.display().to_string()),
179+
)
180+
}
181+
}
182+
135183
fn append_filter_metadata(&self, filter: &FilterCoordinator) -> RecorderResult<()> {
136184
let Some(outputs) = &self.output_paths else {
137185
return Ok(());

codetracer-python-recorder/src/runtime/tracer/runtime_tracer.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ enum ExitPayload {
2828
Text(Cow<'static, str>),
2929
}
3030

31+
#[derive(Clone, Debug, Default)]
32+
pub(crate) struct ExitSummary {
33+
pub code: Option<i32>,
34+
pub label: Option<String>,
35+
}
36+
3137
impl ExitPayload {
3238
fn is_code(&self) -> bool {
3339
matches!(self, ExitPayload::Code(_))
@@ -94,6 +100,19 @@ impl SessionExitState {
94100
fn is_emitted(&self) -> bool {
95101
self.emitted
96102
}
103+
104+
fn summary(&self) -> ExitSummary {
105+
match &self.payload {
106+
ExitPayload::Code(value) => ExitSummary {
107+
code: Some(*value),
108+
label: None,
109+
},
110+
ExitPayload::Text(text) => ExitSummary {
111+
code: None,
112+
label: Some(text.as_ref().to_string()),
113+
},
114+
}
115+
}
97116
}
98117

99118
/// Minimal runtime tracer that maps Python sys.monitoring events to
@@ -202,6 +221,10 @@ impl RuntimeTracer {
202221
self.session_exit.set_exit_code(exit_code);
203222
}
204223

224+
pub(super) fn exit_summary(&self) -> ExitSummary {
225+
self.session_exit.summary()
226+
}
227+
205228
pub(super) fn evaluate_gate(
206229
&mut self,
207230
py: Python<'_>,

codetracer-python-recorder/tests/python/test_exit_payloads.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ def test_cli_records_exit_code_in_toplevel_return(tmp_path: Path) -> None:
4949
assert exit_value["kind"] == "Int"
5050
assert exit_value["i"] == 3
5151

52+
metadata = json.loads((trace_dir / "trace_metadata.json").read_text(encoding="utf-8"))
53+
status = metadata.get("process_exit_status")
54+
assert status == {"code": 3, "label": None}
55+
5256

5357
def test_default_exit_payload_uses_placeholder(tmp_path: Path) -> None:
5458
trace_dir = tmp_path / "trace"
@@ -68,3 +72,7 @@ def test_default_exit_payload_uses_placeholder(tmp_path: Path) -> None:
6872
exit_value = _last_return_value(trace_dir)
6973
assert exit_value["kind"] == "String"
7074
assert exit_value["text"] == "<exit>"
75+
76+
metadata = json.loads((trace_dir / "trace_metadata.json").read_text(encoding="utf-8"))
77+
status = metadata.get("process_exit_status")
78+
assert status == {"code": None, "label": "<exit>"}

codetracer-python-recorder/tests/python/test_trace_balance.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import json
22
import importlib.util
3+
import runpy
34
from pathlib import Path
45
from typing import List, Mapping
56

67
import pytest
78

9+
import codetracer_python_recorder as codetracer
10+
811
from codetracer_python_recorder.trace_balance import (
912
TraceBalanceError,
1013
TraceBalanceResult,
@@ -99,3 +102,79 @@ def test_cli_reports_success_for_balanced_trace(tmp_path: Path, capsys: pytest.C
99102

100103
assert exit_code == 0
101104
assert "Balanced trace" in captured.out
105+
106+
107+
def test_activation_and_filter_skip_still_balances_trace(tmp_path: Path) -> None:
108+
script = tmp_path / "app.py"
109+
script.write_text(
110+
"""
111+
def side_effect():
112+
for _ in range(3):
113+
pass
114+
115+
if __name__ == "__main__":
116+
side_effect()
117+
""",
118+
encoding="utf-8",
119+
)
120+
121+
filter_file = tmp_path / "skip.toml"
122+
filter_file.write_text(
123+
"""
124+
[meta]
125+
name = "skip-main"
126+
version = 1
127+
128+
[scope]
129+
default_exec = "trace"
130+
default_value_action = "allow"
131+
132+
[[scope.rules]]
133+
selector = "pkg:__main__"
134+
exec = "skip"
135+
value_default = "allow"
136+
""",
137+
encoding="utf-8",
138+
)
139+
140+
trace_dir = tmp_path / "trace"
141+
142+
session = codetracer.start(
143+
trace_dir,
144+
format="json",
145+
start_on_enter=script,
146+
trace_filter=[filter_file],
147+
)
148+
try:
149+
runpy.run_path(str(script), run_name="__main__")
150+
finally:
151+
session.stop()
152+
153+
events = load_trace_events(trace_dir / "trace.json")
154+
function_names: dict[int, str] = {}
155+
next_function_id = 0
156+
for event in events:
157+
payload = event.get("Function")
158+
if payload:
159+
function_names[next_function_id] = payload.get("name", "")
160+
next_function_id += 1
161+
162+
toplevel_ids = {fid for fid, name in function_names.items() if name == "<toplevel>"}
163+
assert len(toplevel_ids) == 1, f"expected single toplevel function, saw {toplevel_ids}"
164+
165+
toplevel_call_count = sum(
166+
1
167+
for event in events
168+
if "Call" in event and event["Call"].get("function_id") in toplevel_ids
169+
)
170+
assert toplevel_call_count == 1
171+
172+
exit_returns = [
173+
event["Return"]
174+
for event in events
175+
if "Return" in event and event["Return"].get("return_value", {}).get("text") == "<exit>"
176+
]
177+
assert len(exit_returns) == 1
178+
179+
script_names = {name for name in function_names.values() if name not in {"<toplevel>"}}
180+
assert "side_effect" not in script_names

design-docs/toplevel-exit-and-trace-gating-implementation-plan.status.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,16 @@
3434
- Verification: `just cargo-test` and `just py-test`.
3535

3636
### WS4 – Lifecycle & Metadata Updates
37-
- **Status:** _Not Started_.
37+
- **Status:** _Completed_
38+
- Metadata writer now records `process_exit_status` alongside filter info, and runtime emits a `<toplevel>` return before finalisation.
39+
- Added regression coverage in Python for both exit code and default placeholder metadata values.
40+
- Verification: `just cargo-test`, `just py-test`.
3841

3942
### WS5 – Validation & Parity Follow-Up
40-
- **Status:** _Not Started_.
43+
- **Status:** _Completed_
44+
- Added explicit tests verifying exit metadata plus activation/filter interplay keeps the trace balanced (`test_exit_payloads`, `test_trace_balance`).
45+
- Documented follow-up to mirror exit-status support in the pure-Python recorder before release.
46+
- Verification: `just cargo-test`, `just py-test`.
4147

4248
## Notes
4349
- API changes will require a minor version bump once runtime support lands; capture release planning tasks after WS2.

0 commit comments

Comments
 (0)