Skip to content

Commit 45ec79c

Browse files
authored
Toplevel return event (#62)
We now emit a Return event at the end of the trace which contains the exit code of the script which we are tracing. We have also consolidated the logic which decides whether to handle an event or not. We have combined the activation and filtering logic into a
2 parents 96a1e32 + aa3ba72 commit 45ec79c

File tree

16 files changed

+702
-88
lines changed

16 files changed

+702
-88
lines changed

codetracer-python-recorder/codetracer_python_recorder/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ def main(argv: Iterable[str] | None = None) -> int:
287287
try:
288288
flush()
289289
finally:
290-
stop()
290+
stop(exit_code=exit_code)
291291
sys.argv = old_argv
292292

293293
_serialise_metadata(trace_dir, script=script_path)

codetracer-python-recorder/codetracer_python_recorder/session.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,17 @@ def __init__(self, path: Path, format: str) -> None:
3838
self.path = path
3939
self.format = format
4040

41-
def stop(self) -> None:
42-
"""Stop this trace session."""
41+
def stop(self, *, exit_code: int | None = None) -> None:
42+
"""Stop this trace session.
43+
44+
Parameters
45+
----------
46+
exit_code:
47+
Optional process exit status to forward to the recorder backend.
48+
When ``None``, the session shutdown reason remains unspecified.
49+
"""
4350
if _active_session is self:
44-
stop()
51+
stop(exit_code=exit_code)
4552

4653
def flush(self) -> None:
4754
"""Flush buffered trace data for this session."""
@@ -51,6 +58,7 @@ def __enter__(self) -> "TraceSession":
5158
return self
5259

5360
def __exit__(self, exc_type, exc, tb) -> None: # pragma: no cover - thin wrapper
61+
# Exit codes are not tracked for context-managed sessions; report unknown.
5462
self.stop()
5563

5664

@@ -121,12 +129,18 @@ def start(
121129
return session
122130

123131

124-
def stop() -> None:
125-
"""Stop the active trace session if one is running."""
132+
def stop(*, exit_code: int | None = None) -> None:
133+
"""Stop the active trace session if one is running.
134+
135+
Parameters
136+
----------
137+
exit_code:
138+
Optional process exit status to forward to the backend.
139+
"""
126140
global _active_session
127141
if not _is_tracing_backend():
128142
return
129-
_stop_backend()
143+
_stop_backend(exit_code)
130144
_active_session = None
131145

132146

codetracer-python-recorder/src/monitoring/api.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ pub trait Tracer: Send + Any {
107107
Ok(())
108108
}
109109

110+
/// Provide the process exit status ahead of tracer teardown.
111+
fn set_exit_status(
112+
&mut self,
113+
_py: Python<'_>,
114+
_exit_code: Option<i32>,
115+
) -> PyResult<()> {
116+
Ok(())
117+
}
118+
110119
/// Called on resumption of a generator/coroutine (not via throw()).
111120
fn on_py_resume(
112121
&mut self,

codetracer-python-recorder/src/monitoring/install.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,11 @@ pub fn flush_installed_tracer(py: Python<'_>) -> PyResult<()> {
8282
}
8383
Ok(())
8484
}
85+
86+
/// Provide the session exit status to the active tracer if one is installed.
87+
pub fn update_exit_status(py: Python<'_>, exit_code: Option<i32>) -> PyResult<()> {
88+
if let Some(global) = GLOBAL.lock().unwrap().as_mut() {
89+
global.tracer.set_exit_status(py, exit_code)?;
90+
}
91+
Ok(())
92+
}

codetracer-python-recorder/src/monitoring/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ pub(crate) mod install;
1010
pub mod tracer;
1111

1212
pub use api::Tracer;
13-
pub use install::{flush_installed_tracer, install_tracer, uninstall_tracer};
13+
pub use install::{
14+
flush_installed_tracer,
15+
install_tracer,
16+
uninstall_tracer,
17+
update_exit_status,
18+
};
1419

1520
const MONITORING_TOOL_NAME: &str = "codetracer";
1621

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

Lines changed: 27 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
//! Event handling pipeline for `RuntimeTracer`.
22
3-
use super::filtering::TraceDecision;
43
use super::runtime_tracer::RuntimeTracer;
54
use crate::code_object::CodeObjectWrapper;
65
use crate::ffi;
@@ -177,18 +176,8 @@ impl Tracer for RuntimeTracer {
177176
code: &CodeObjectWrapper,
178177
_offset: i32,
179178
) -> CallbackResult {
180-
let is_active = self
181-
.lifecycle
182-
.activation_mut()
183-
.should_process_event(py, code);
184-
if matches!(
185-
self.should_trace_code(py, code),
186-
TraceDecision::SkipAndDisable
187-
) {
188-
return Ok(CallbackOutcome::Continue);
189-
}
190-
if !is_active {
191-
return Ok(CallbackOutcome::Continue);
179+
if let Some(outcome) = self.evaluate_gate(py, code, true) {
180+
return Ok(outcome);
192181
}
193182

194183
if should_inject_failure(FailureStage::PyStart) {
@@ -244,18 +233,8 @@ impl Tracer for RuntimeTracer {
244233
code: &CodeObjectWrapper,
245234
_offset: i32,
246235
) -> CallbackResult {
247-
let is_active = self
248-
.lifecycle
249-
.activation_mut()
250-
.should_process_event(py, code);
251-
if matches!(
252-
self.should_trace_code(py, code),
253-
TraceDecision::SkipAndDisable
254-
) {
255-
return Ok(CallbackOutcome::Continue);
256-
}
257-
if !is_active {
258-
return Ok(CallbackOutcome::Continue);
236+
if let Some(outcome) = self.evaluate_gate(py, code, false) {
237+
return Ok(outcome);
259238
}
260239

261240
log_event(py, code, "on_py_resume", None);
@@ -264,18 +243,8 @@ impl Tracer for RuntimeTracer {
264243
}
265244

266245
fn on_line(&mut self, py: Python<'_>, code: &CodeObjectWrapper, lineno: u32) -> CallbackResult {
267-
let is_active = self
268-
.lifecycle
269-
.activation_mut()
270-
.should_process_event(py, code);
271-
if matches!(
272-
self.should_trace_code(py, code),
273-
TraceDecision::SkipAndDisable
274-
) {
275-
return Ok(CallbackOutcome::Continue);
276-
}
277-
if !is_active {
278-
return Ok(CallbackOutcome::Continue);
246+
if let Some(outcome) = self.evaluate_gate(py, code, false) {
247+
return Ok(outcome);
279248
}
280249

281250
if should_inject_failure(FailureStage::Line) {
@@ -378,18 +347,8 @@ impl Tracer for RuntimeTracer {
378347
_offset: i32,
379348
exception: &Bound<'_, PyAny>,
380349
) -> CallbackResult {
381-
let is_active = self
382-
.lifecycle
383-
.activation_mut()
384-
.should_process_event(py, code);
385-
if matches!(
386-
self.should_trace_code(py, code),
387-
TraceDecision::SkipAndDisable
388-
) {
389-
return Ok(CallbackOutcome::Continue);
390-
}
391-
if !is_active {
392-
return Ok(CallbackOutcome::Continue);
350+
if let Some(outcome) = self.evaluate_gate(py, code, false) {
351+
return Ok(outcome);
393352
}
394353

395354
log_event(py, code, "on_py_throw", None);
@@ -439,8 +398,17 @@ impl Tracer for RuntimeTracer {
439398
)
440399
}
441400

401+
fn set_exit_status(
402+
&mut self,
403+
_py: Python<'_>,
404+
exit_code: Option<i32>,
405+
) -> PyResult<()> {
406+
self.record_exit_status(exit_code);
407+
Ok(())
408+
}
409+
442410
fn notify_failure(&mut self, _py: Python<'_>) -> PyResult<()> {
443-
self.mark_failure();
411+
self.mark_disabled();
444412
Ok(())
445413
}
446414

@@ -484,9 +452,14 @@ impl Tracer for RuntimeTracer {
484452
self.mark_event();
485453
}
486454

455+
self.emit_session_exit(py);
456+
457+
let exit_summary = self.exit_summary();
458+
487459
if self.lifecycle.encountered_failure() {
488460
if policy.keep_partial_trace {
489-
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+
{
490463
with_error_code(ErrorCode::TraceIncomplete, || {
491464
log::warn!(
492465
"failed to finalise partial trace after disable: {}",
@@ -519,7 +492,7 @@ impl Tracer for RuntimeTracer {
519492
.require_trace_or_fail(&policy)
520493
.map_err(ffi::map_recorder_error)?;
521494
self.lifecycle
522-
.finalise(&mut self.writer, &self.filter)
495+
.finalise(&mut self.writer, &self.filter, &exit_summary)
523496
.map_err(ffi::map_recorder_error)?;
524497
self.function_ids.clear();
525498
self.module_names.clear();
@@ -553,22 +526,8 @@ impl RuntimeTracer {
553526
exit_kind: Option<ActivationExitKind>,
554527
allow_disable: bool,
555528
) -> CallbackResult {
556-
let is_active = self
557-
.lifecycle
558-
.activation_mut()
559-
.should_process_event(py, code);
560-
if matches!(
561-
self.should_trace_code(py, code),
562-
TraceDecision::SkipAndDisable
563-
) {
564-
return Ok(if allow_disable {
565-
CallbackOutcome::DisableLocation
566-
} else {
567-
CallbackOutcome::Continue
568-
});
569-
}
570-
if !is_active {
571-
return Ok(CallbackOutcome::Continue);
529+
if let Some(outcome) = self.evaluate_gate(py, code, allow_disable) {
530+
return Ok(outcome);
572531
}
573532

574533
log_event(py, code, label, None);

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(());

0 commit comments

Comments
 (0)