Skip to content

Commit 3e5824d

Browse files
authored
Record function arguments in the trace (#36)
(AI-generated spec based on the contents of this PR) # Tracing Function Arguments on Entry and Structured Value Encoding This specification defines how to capture Python function arguments at the moment a function starts executing (the PY_START event) and how to encode argument values into the runtime tracing format. It also defines fail‑fast error behavior for the monitoring callback and the test expectations that validate the behavior. Audience: Junior developers familiar with Rust and Python, but with no prior knowledge of CPython frames or this codebase. ## Executive Summary - Record function arguments on PY_START for all Python parameter kinds: positional‑only, positional‑or‑keyword, keyword‑only, varargs (`*args`), and kwargs (`**kwargs`). - Encode values canonically and structurally: - `None`, `bool`, `int`, `str` as dedicated kinds (`None`, `Bool`, `Int`, `String`). - Python `tuple` → `Tuple` with recursively encoded elements. - Python `list` → `Sequence` with recursively encoded elements. - Python `dict` → `Sequence` of `(key, value)` `Tuple`s. Keys are encoded as `String` when possible; otherwise, encode the key normally. - Fail fast on irrecoverable errors during argument capture: raise a Python exception and immediately disable further monitoring callbacks for the session. - Tests assert argument presence, name mapping, stable string encoding, and structured kwargs. - Add `.cargo/` to version control ignore rules. ## Goals and Non‑Goals Goals - Capture and emit all Python argument kinds on function entry. - Preserve structure of varargs and kwargs values where possible. - Provide deterministic, canonical encoding for common primitives. - Fail fast on errors (no silent fallbacks) and disable further monitoring after the first callback error. - Provide clear, verifiable test criteria. Non‑Goals - Introducing a new mapping kind to the value schema (we reuse existing `Sequence` + `Tuple`). - Changing higher‑level tracing schemas or writer behavior beyond what is needed to attach arguments to `Call` events. - Unifying cross‑recorder type naming (e.g., “List” vs “Array”) beyond the choices specified here. ## Background: CPython Frames and Code Objects (Quick Primer) At the beginning of a Python function call, CPython creates a frame with locals bound for the call. The function’s code object carries metadata describing its parameters. Key code object attributes used here (CPython 3.8+): - `co_varnames`: A tuple of local variable names. Parameters appear first in a defined order. - `co_argcount`: Total count of positional parameters. Important: in Python 3.8+, this total includes positional‑only and positional‑or‑keyword parameters (see PEP 570: Positional‑Only Parameters). - `co_posonlyargcount`: Count of positional‑only parameters. Useful only if you need to distinguish subgroups; we do not for this feature. - `co_kwonlyargcount`: Count of keyword‑only parameters. - `co_flags`: Bitmask; `0x04` indicates presence of `*args` (varargs), `0x08` indicates presence of `**kwargs` (varkeywords). Reference terms: PEP 570 (Positional‑Only Parameters) and CPython code object docs. ## High‑Level Design When the monitoring system delivers a PY_START event, we: 1. Ensure the tracer is started for the code object and obtain a function id. 2. Obtain the current frame via `sys._getframe(0)` and the frame’s locals (`f_locals`). 3. Compute the ordered list of parameter names directly from the code object, using CPython ordering, and look up each name in `f_locals`. 4. Encode each found value using `encode_value` and attach the resulting `args` vector to the `Call` event payload via the trace writer. 5. If any irrecoverable error occurs (e.g., `_getframe` unavailable), raise a Python exception and immediately disable further monitoring (fail fast). ## Parameter Ordering and Name Discovery Given a bound code object and Python 3.8+ semantics: - Let `pos_count = co_argcount` (total positional parameters, including positional‑only and positional‑or‑keyword). Do not add `co_posonlyargcount` to this figure (that would double count). - Let `kwonly_count = co_kwonlyargcount`. - Let `flags = co_flags`. - Let `varnames = list(co_varnames)`. Derive the ordered parameter names from `varnames`: - Positional parameters: `varnames[0 : min(pos_count, len(varnames))]`. - Varargs (`*args`): if `flags & 0x04 != 0`, then next name is `varnames[idx]`. - Keyword‑only parameters: the next `kwonly_count` names. - Kwargs (`**kwargs`): if `flags & 0x08 != 0`, then next name is `varnames[idx]`. For each name in this sequence, try to fetch the value from `f_locals[name]`: - If present, encode it and include it. - If absent or retrieval fails, skip it silently (locals may not have been populated for some names in unusual interpreter states, but this should be rare at function entry). ## Value Encoding Rules (`encode_value`) Encode a Python object to a `ValueRecord` used by the trace writer. The encoder must be recursive and must follow these canonical rules: Primitives and None - `None` → special `NONE_VALUE` constant. - `bool` → `Bool` with appropriate `type_id`. - `int` → `Int` with appropriate `type_id`. - `str` → `String` with exact text. This is canonical for text; do not fall back to `Raw` for `str`. Containers - Python `tuple` → `Tuple` with `elements = [encode_value(item) for item in tuple]`. - Python `list` → `Sequence` with `elements = [encode_value(item) for item in list]`, `is_slice = false`, and language type name “List”. - Python `dict` → represent as a `Sequence` with language type name “Dict”, whose elements are 2‑element `Tuple`s `(key, value)`. - Encode keys as `String` when `key` is a Python `str`. - If a key is not a `str`, encode the key using normal rules (best effort). Kwarg keys are always strings, so in kwargs contexts you will observe `String` keys. Fallback - For all other types, obtain a textual representation and encode as `Raw` with language type name “Object”. Type registration - For every concrete kind you emit, register or look up a `type_id` via `TraceWriter::ensure_type_id(...)`, using the following language type names: - `Bool` → "Bool" - `Int` → "Int" - `String` → "String" - `Tuple` → "Tuple" - `Sequence` (Python list) → "List" - `Sequence` (Python dict encoded as sequence of pairs) → "Dict" - `Raw` → "Object" ## Attaching Arguments to the Call Event For each discovered parameter name and encoded value: - Create a full value record using `TraceWriter::arg(writer, name, value_record)`. - Accumulate these into a `Vec<FullValueRecord>`. - Emit the `Call` event via `TraceWriter::register_call(writer, function_id, args_vec)`. Note: The writer manages a variable‑name table. Each argument will reference a `variable_id` that can be resolved to the actual name through separate `VariableName` events. ## Error Handling and Fail‑Fast Behavior `on_py_start` must return `PyResult<()>` instead of `()`. Behavior: - On success: return `Ok(())`. - On irrecoverable error (e.g., `_getframe` import or call fails, accessing locals fails in a way that prevents capture): - Return `Err(PyRuntimeError("on_py_start: failed to capture args: <reason>"))`. - The callback wrapper (see below) must immediately disable future monitoring for this tool by setting events to `NO_EVENTS` and propagate the error to Python. Callback wrapper behavior (PY_START only is specified, but approach generalizes): - Acquire the global tracer context. - Invoke `on_py_start` and match on the `PyResult`. - `Ok(())`: return `Ok(())`. - `Err(err)`: call `set_events(py, &tool, NO_EVENTS)` to turn off events for this session, log an error, and return `Err(err)`. - If the global context is absent, return `Ok(())` (no tracing active). Rationale: Turning off events on first error prevents repeated exceptions during interpreter activities like error printing (which otherwise trigger more PY_START events). ## Test Specifications Parsing helper changes (Python side) - Extend the trace parsing helper to collect: - `varnames: List[str]` from `VariableName` events (index is `variable_id`). - `call_records: List[Dict[str, Any]]` from raw `Call` payloads (to inspect args). Test: record positional arguments on entry - Create a script: - `def foo(a, b): return a if len(str(b)) > 0 else 0` - Call `foo(1, 'x')` under tracing. - Assert: - A `Call` for `foo` exists with two arguments. - Arg 0: name `a`, value kind `Int`, value `1`. - Arg 1: name `b`, value kind `String`, text `"x"`. Test: record all Python argument kinds - Create a script: - `def g(p, /, q, *args, r, **kwargs): ...` - Call `g(10, 20, 30, 40, r=50, k=60)` under tracing. - Assert: - Names present: `p`, `q`, `args`, `r`, `kwargs`. - `p == 10`, `q == 20`, `r == 50` as `Int`. - Varargs (`args`) is either: - `Sequence` or `Tuple` with exactly two elements `30`, `40` as `Int`, or - `Raw` whose text contains `"30"` and `"40"` (accepted to keep compatibility with alternative backends). - Kwargs (`kwargs`) is structured as: - kind `Sequence` with one element, which is - kind `Tuple` of two elements: key record kind `String` with text `"k"`; value record kind `Int` with `60`. Test: fail fast when frame access fails (Rust module test via PyO3) - Start tracing with activation scoped to the test program path. - Monkeypatch `sys._getframe` to raise `RuntimeError` when called. - Execute a trivial program that triggers a Python function call under tracing. - Expect a raised exception containing `_getframe` info. - Execute the program again in the same process: no exception should be raised because monitoring has been disabled. - Restore `_getframe` and stop tracing. Rust test fixture adaptation - Any `Tracer` implementations used by tests must update `on_py_start` signature to return `PyResult<()>` and return `Ok(())` when no special logic is needed. ## Implementation Details (Where and How) Files and responsibilities - `src/runtime_tracer.rs` - Implement/extend `encode_value(py, value)` per the rules above, using `TraceWriter::ensure_type_id(...)` for type registration. - Change `on_py_start(py, code, offset)` to return `PyResult<()>` and implement argument capture: - Ensure tracer started and `function_id` available. - Build ordered parameter list from the code object (`co_varnames`, `co_argcount`, `co_kwonlyargcount`, `co_flags`). Do not double count positional‑only. - Obtain `f_locals` and collect values by name. - Encode values and build `args` with `TraceWriter::arg`. - Register the call via `TraceWriter::register_call(writer, fid, args)`. - Fail fast by returning `Err(...)` if frame/locals access fails. - `src/tracer.rs` - Change the `Tracer` trait method signature: `fn on_py_start(...) -> PyResult<()>`. - Update docs for fail‑fast guidance. - Update the callback wrapper `callback_py_start` to: - Call `on_py_start` and match on the result. - On `Err`, call `set_events(py, &tool, NO_EVENTS)`, log, and return the error. - `test/test_monitoring_events.py` - Extend parser to collect `varnames` and `call_records`. - Add the two tests specified above. - `tests/test_fail_fast_on_py_start.py` - Add the Python test that monkeypatches `_getframe` and asserts fail‑fast behavior with monitoring disabled after the first error. - `.gitignore` - Add `.cargo/` to exclude Cargo cache/config directories from version control. ## Edge Cases and Defensive Choices - Missing locals for some parameter names are skipped. This is rare at function start but should not crash the tracer. - Deeply nested containers are recursively encoded. Extremely deep structures may be expensive; this is acceptable for now. - Dict encoding is general (applies to any Python `dict`), but kwargs contexts will always produce string keys. Non‑string keys are encoded normally. - We intentionally do not modify module‑level activation flags during fail‑fast; turning off events is sufficient to prevent further callbacks, and explicit shutdown remains idempotent. ## Acceptance Criteria - At least one `Call` event for the tested functions contains a non‑empty `args` vector. - Names and values for positional parameters match exactly, including canonical `String` for Python `str`. - `*args` and `**kwargs` are present and encoded according to the rules above. - When `_getframe` raises, the initial call propagates an exception and subsequent calls do not re‑raise because monitoring was disabled. - Tests described in this spec pass. ## Future Work - Unify list/sequence language type naming across recorders (e.g., consistently "List"). - Consider introducing a dedicated mapping value kind for dictionaries to avoid overloading `Sequence`. - Consider stricter behavior for non‑string dict keys in non‑kwargs contexts (fail vs. best effort).
2 parents 9008759 + 1dfea0f commit 3e5824d

File tree

7 files changed

+650
-11
lines changed

7 files changed

+650
-11
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
**/target/
66
build
77
*~
8-
.idea/
8+
.idea/
9+
.cargo/

codetracer-python-recorder/src/runtime_tracer.rs

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::path::{Path, PathBuf};
22

33
use pyo3::prelude::*;
4-
use pyo3::types::PyAny;
4+
use pyo3::types::{PyAny, PyList, PyTuple, PyDict};
55

66
use runtime_tracing::{Line, TraceEventsFileFormat, TraceWriter, TypeKind, ValueRecord, NONE_VALUE};
77
use runtime_tracing::NonStreamingTraceWriter;
@@ -77,6 +77,17 @@ impl RuntimeTracer {
7777
}
7878
}
7979

80+
/// Encode a Python value into a `ValueRecord` used by the trace writer.
81+
///
82+
/// Canonical rules:
83+
/// - `None` -> `NONE_VALUE`
84+
/// - `bool` -> `Bool`
85+
/// - `int` -> `Int`
86+
/// - `str` -> `String` (canonical for text; do not fall back to Raw)
87+
/// - common containers:
88+
/// - Python `tuple` -> `Tuple` with encoded elements
89+
/// - Python `list` -> `Sequence` with encoded elements (not a slice)
90+
/// - any other type -> textual `Raw` via `__str__` best-effort
8091
fn encode_value<'py>(&mut self, _py: Python<'py>, v: &Bound<'py, PyAny>) -> ValueRecord {
8192
// None
8293
if v.is_none() {
@@ -91,11 +102,63 @@ impl RuntimeTracer {
91102
let ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::Int, "Int");
92103
return ValueRecord::Int { i, type_id: ty };
93104
}
105+
// Strings are encoded canonically as `String` to ensure stable tests
106+
// and downstream processing. Falling back to `Raw` for `str` is
107+
// not allowed.
94108
if let Ok(s) = v.extract::<String>() {
95109
let ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::String, "String");
96110
return ValueRecord::String { text: s, type_id: ty };
97111
}
98112

113+
// Python tuple -> ValueRecord::Tuple with recursively-encoded elements
114+
if let Ok(t) = v.downcast::<PyTuple>() {
115+
let mut elements: Vec<ValueRecord> = Vec::with_capacity(t.len());
116+
for item in t.iter() {
117+
// item: Bound<PyAny>
118+
elements.push(self.encode_value(_py, &item));
119+
}
120+
let ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::Tuple, "Tuple");
121+
return ValueRecord::Tuple { elements, type_id: ty };
122+
}
123+
124+
// Python list -> ValueRecord::Sequence with recursively-encoded elements
125+
if let Ok(l) = v.downcast::<PyList>() {
126+
let mut elements: Vec<ValueRecord> = Vec::with_capacity(l.len());
127+
for item in l.iter() {
128+
elements.push(self.encode_value(_py, &item));
129+
}
130+
let ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::Seq, "List");
131+
return ValueRecord::Sequence { elements, is_slice: false, type_id: ty };
132+
}
133+
134+
// Python dict -> represent as a Sequence of (key, value) Tuples.
135+
// Keys are expected to be strings for kwargs; for non-str keys we
136+
// fall back to best-effort encoding of the key.
137+
if let Ok(d) = v.downcast::<PyDict>() {
138+
let seq_ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::Seq, "Dict");
139+
let tuple_ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::Tuple, "Tuple");
140+
let str_ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::String, "String");
141+
let mut elements: Vec<ValueRecord> = Vec::with_capacity(d.len());
142+
let items = d.items();
143+
for pair in items.iter() {
144+
if let Ok(t) = pair.downcast::<PyTuple>() {
145+
if t.len() == 2 {
146+
let key_obj = t.get_item(0).unwrap();
147+
let val_obj = t.get_item(1).unwrap();
148+
let key_rec = if let Ok(s) = key_obj.extract::<String>() {
149+
ValueRecord::String { text: s, type_id: str_ty }
150+
} else {
151+
self.encode_value(_py, &key_obj)
152+
};
153+
let val_rec = self.encode_value(_py, &val_obj);
154+
let pair_rec = ValueRecord::Tuple { elements: vec![key_rec, val_rec], type_id: tuple_ty };
155+
elements.push(pair_rec);
156+
}
157+
}
158+
}
159+
return ValueRecord::Sequence { elements, is_slice: false, type_id: seq_ty };
160+
}
161+
99162
// Fallback to Raw string representation
100163
let ty = TraceWriter::ensure_type_id(&mut self.writer, TypeKind::Raw, "Object");
101164
match v.str() {
@@ -124,10 +187,10 @@ impl Tracer for RuntimeTracer {
124187
events_union(&[events.PY_START, events.LINE, events.PY_RETURN])
125188
}
126189

127-
fn on_py_start(&mut self, py: Python<'_>, code: &CodeObjectWrapper, _offset: i32) {
190+
fn on_py_start(&mut self, py: Python<'_>, code: &CodeObjectWrapper, _offset: i32) -> PyResult<()> {
128191
// Activate lazily if configured; ignore until then
129192
self.ensure_started(py, code);
130-
if !self.started { return; }
193+
if !self.started { return Ok(()); }
131194
// Trace event entry
132195
match (code.filename(py), code.qualname(py)) {
133196
(Ok(fname), Ok(qname)) => {
@@ -136,8 +199,99 @@ impl Tracer for RuntimeTracer {
136199
_ => log::debug!("[RuntimeTracer] on_py_start"),
137200
}
138201
if let Ok(fid) = self.ensure_function_id(py, code) {
139-
TraceWriter::register_call(&mut self.writer, fid, Vec::new());
202+
// Attempt to capture function arguments from the current frame.
203+
// Fail fast on any error per source-code rules.
204+
let mut args: Vec<runtime_tracing::FullValueRecord> = Vec::new();
205+
let frame_and_args = (|| -> PyResult<()> {
206+
// Current Python frame where the function just started executing
207+
let sys = py.import("sys")?;
208+
let frame = sys.getattr("_getframe")?.call1((0,))?;
209+
let locals = frame.getattr("f_locals")?;
210+
211+
// Argument names come from co_varnames in the order defined by CPython:
212+
// [positional (pos-only + pos-or-kw)] [+ varargs] [+ kw-only] [+ kwargs]
213+
// In CPython 3.8+ semantics, `co_argcount` is the TOTAL number of positional
214+
// parameters (including positional-only and pos-or-keyword). Use it directly
215+
// for the positional slice; `co_posonlyargcount` is only needed if we want to
216+
// distinguish the two groups, which we do not here.
217+
let argcount = code.arg_count(py)? as usize; // total positional (pos-only + pos-or-kw)
218+
let posonly: usize = code
219+
.as_bound(py)
220+
.getattr("co_posonlyargcount")?
221+
.extract()?;
222+
let kwonly: usize = code
223+
.as_bound(py)
224+
.getattr("co_kwonlyargcount")?
225+
.extract()?;
226+
227+
let flags = code.flags(py)?;
228+
const CO_VARARGS: u32 = 0x04;
229+
const CO_VARKEYWORDS: u32 = 0x08;
230+
231+
let varnames_obj = code.as_bound(py).getattr("co_varnames")?;
232+
let varnames: Vec<String> = varnames_obj.extract()?;
233+
234+
// 1) Positional parameters (pos-only + pos-or-kw)
235+
let mut idx = 0usize;
236+
// `argcount` already includes positional-only parameters
237+
let take_n = std::cmp::min(argcount, varnames.len());
238+
for name in varnames.iter().take(take_n) {
239+
match locals.get_item(name) {
240+
Ok(val) => {
241+
let vrec = self.encode_value(py, &val);
242+
args.push(TraceWriter::arg(&mut self.writer, name, vrec));
243+
}
244+
Err(_) => {}
245+
}
246+
idx += 1;
247+
}
248+
249+
// 2) Varargs (*args)
250+
if (flags & CO_VARARGS) != 0 && idx < varnames.len() {
251+
let name = &varnames[idx];
252+
if let Ok(val) = locals.get_item(name) {
253+
let vrec = self.encode_value(py, &val);
254+
args.push(TraceWriter::arg(&mut self.writer, name, vrec));
255+
}
256+
idx += 1;
257+
}
258+
259+
// 3) Keyword-only parameters
260+
let kwonly_take = std::cmp::min(kwonly, varnames.len().saturating_sub(idx));
261+
for name in varnames.iter().skip(idx).take(kwonly_take) {
262+
match locals.get_item(name) {
263+
Ok(val) => {
264+
let vrec = self.encode_value(py, &val);
265+
args.push(TraceWriter::arg(&mut self.writer, name, vrec));
266+
}
267+
Err(_) => {}
268+
}
269+
}
270+
idx = idx.saturating_add(kwonly_take);
271+
272+
// 4) Kwargs (**kwargs)
273+
if (flags & CO_VARKEYWORDS) != 0 && idx < varnames.len() {
274+
let name = &varnames[idx];
275+
if let Ok(val) = locals.get_item(name) {
276+
let vrec = self.encode_value(py, &val);
277+
args.push(TraceWriter::arg(&mut self.writer, name, vrec));
278+
}
279+
}
280+
Ok(())
281+
})();
282+
if let Err(e) = frame_and_args {
283+
// Raise a clear error; do not silently continue with empty args.
284+
let rete =Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
285+
"on_py_start: failed to capture args: {}",
286+
e
287+
)));
288+
log::debug!("error {:?}", rete);
289+
return rete;
290+
}
291+
292+
TraceWriter::register_call(&mut self.writer, fid, args);
140293
}
294+
Ok(())
141295
}
142296

143297
fn on_line(&mut self, py: Python<'_>, code: &CodeObjectWrapper, lineno: u32) {

codetracer-python-recorder/src/tracer.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@ pub trait Tracer: Send + Any {
190190
}
191191

192192
/// Called at start of a Python function (frame on stack).
193-
fn on_py_start(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, _offset: i32) {}
193+
///
194+
/// Implementations should fail fast on irrecoverable conditions
195+
/// (e.g., inability to access the current frame/locals) by
196+
/// returning an error.
197+
fn on_py_start(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, _offset: i32) -> PyResult<()> { Ok(()) }
194198

195199
/// Called on resumption of a generator/coroutine (not via throw()).
196200
fn on_py_resume(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, _offset: i32) {}
@@ -557,9 +561,19 @@ fn callback_branch(
557561
fn callback_py_start(py: Python<'_>, code: Bound<'_, PyCode>, instruction_offset: i32) -> PyResult<()> {
558562
if let Some(global) = GLOBAL.lock().unwrap().as_mut() {
559563
let wrapper = global.registry.get_or_insert(py, &code);
560-
global.tracer.on_py_start(py, &wrapper, instruction_offset);
564+
match global.tracer.on_py_start(py, &wrapper, instruction_offset) {
565+
Ok(()) => Ok(()),
566+
Err(err) => {
567+
// Disable further monitoring immediately on first callback error.
568+
// Soft-stop within this lock to avoid deadlocking on GLOBAL.
569+
let _ = set_events(py, &global.tool, NO_EVENTS);
570+
log::error!("Event monitoring turned off due to exception. No new events will be recorded! {}", err);
571+
Err(err)
572+
}
573+
}
574+
} else {
575+
Ok(())
561576
}
562-
Ok(())
563577
}
564578

565579
#[pyfunction]

0 commit comments

Comments
 (0)