Skip to content

Commit 4414c0e

Browse files
committed
feat: add monitoring tracer trait and hooks
1 parent ed81331 commit 4414c0e

File tree

7 files changed

+192
-3
lines changed

7 files changed

+192
-3
lines changed

Justfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dev:
3434
# Run unit tests of dev build
3535
test:
3636
uv run --group dev --group test pytest
37+
uv run --group dev --group test cargo test --manifest-path codetracer-python-recorder/Cargo.toml
3738

3839
# Run tests only on the pure recorder
3940
test-pure:

codetracer-python-recorder/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codetracer-python-recorder/Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ repository = "https://github.com/metacraft-labs/codetracer-python-recorder"
88

99
[lib]
1010
name = "codetracer_python_recorder"
11-
crate-type = ["cdylib"]
11+
crate-type = ["cdylib", "rlib"]
1212

1313
[dependencies]
14-
pyo3 = { version = "0.25.1", features = ["extension-module"] }
15-
runtime_tracing = "0.14.0"
14+
pyo3 = { version = "0.25.1", features = ["extension-module", "abi3-py312"] }
15+
runtime_tracing = "0.14.0"
16+
bitflags = "2.4"

codetracer-python-recorder/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
33
use pyo3::exceptions::PyRuntimeError;
44
use pyo3::prelude::*;
55

6+
pub mod tracer;
7+
pub use crate::tracer::{install_tracer, uninstall_tracer, EventMask, Tracer};
8+
69
/// Global flag tracking whether tracing is active.
710
static ACTIVE: AtomicBool = AtomicBool::new(false);
811

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use std::sync::Mutex;
2+
3+
use bitflags::bitflags;
4+
use pyo3::{
5+
exceptions::PyRuntimeError,
6+
prelude::*,
7+
types::{PyModule, PyTuple},
8+
ffi,
9+
};
10+
11+
bitflags! {
12+
/// Bitmask of monitoring events a tracer can subscribe to.
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+
pub struct EventMask: u32 {
15+
/// Python function calls.
16+
const CALL = 1 << 0;
17+
/// Line execution events.
18+
const LINE = 1 << 1;
19+
}
20+
}
21+
22+
#[non_exhaustive]
23+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24+
pub enum Event {
25+
Call,
26+
Line,
27+
}
28+
29+
/// Trait implemented by tracing backends.
30+
///
31+
/// Each method corresponds to an event from `sys.monitoring`. Default
32+
/// implementations allow implementers to only handle the events they care
33+
/// about.
34+
pub trait Tracer: Send {
35+
/// Return the set of events the tracer wants to receive.
36+
fn interest(&self) -> EventMask { EventMask::empty() }
37+
38+
/// Called on Python function calls.
39+
fn on_call(&mut self, _py: Python<'_>, _frame: *mut ffi::PyFrameObject) {}
40+
41+
/// Called on line execution.
42+
fn on_line(&mut self, _py: Python<'_>, _frame: *mut ffi::PyFrameObject, _lineno: u32) {}
43+
}
44+
45+
/// Dispatcher routing events based on the tracer's interest mask.
46+
pub struct Dispatcher {
47+
tracer: Box<dyn Tracer>,
48+
mask: EventMask,
49+
}
50+
51+
impl Dispatcher {
52+
pub fn new(tracer: Box<dyn Tracer>) -> Self {
53+
let mask = tracer.interest();
54+
Self { tracer, mask }
55+
}
56+
57+
pub fn dispatch_call(&mut self, py: Python<'_>, frame: *mut ffi::PyFrameObject) {
58+
if self.mask.contains(EventMask::CALL) {
59+
self.tracer.on_call(py, frame);
60+
}
61+
}
62+
63+
pub fn dispatch_line(&mut self, py: Python<'_>, frame: *mut ffi::PyFrameObject, lineno: u32) {
64+
if self.mask.contains(EventMask::LINE) {
65+
self.tracer.on_line(py, frame, lineno);
66+
}
67+
}
68+
}
69+
70+
struct Global {
71+
dispatcher: Dispatcher,
72+
tool_id: u8,
73+
callbacks: Vec<Py<PyAny>>,
74+
}
75+
76+
static GLOBAL: Mutex<Option<Global>> = Mutex::new(None);
77+
78+
/// Install a tracer and hook it into Python's `sys.monitoring`.
79+
pub fn install_tracer(py: Python<'_>, tracer: Box<dyn Tracer>) -> PyResult<()> {
80+
let mask = tracer.interest();
81+
let mut guard = GLOBAL.lock().unwrap();
82+
if guard.is_some() {
83+
return Err(PyRuntimeError::new_err("tracer already installed"));
84+
}
85+
let monitoring = py.import("sys")?.getattr("monitoring")?;
86+
let tool_id: u8 = monitoring.call_method1("use_tool_id", ("codetracer",))?.extract()?;
87+
let events = monitoring.getattr("events")?;
88+
let module = PyModule::new(py, "_codetracer_callbacks")?;
89+
90+
let mut callbacks = Vec::new();
91+
if mask.contains(EventMask::CALL) {
92+
module.add_function(wrap_pyfunction!(callback_call, &module)?)?;
93+
let cb = module.getattr("callback_call")?;
94+
let ev = events.getattr("CALL")?;
95+
monitoring.call_method("register_callback", (tool_id, ev, &cb), None)?;
96+
callbacks.push(cb.unbind());
97+
}
98+
if mask.contains(EventMask::LINE) {
99+
module.add_function(wrap_pyfunction!(callback_line, &module)?)?;
100+
let cb = module.getattr("callback_line")?;
101+
let ev = events.getattr("LINE")?;
102+
monitoring.call_method("register_callback", (tool_id, ev, &cb), None)?;
103+
callbacks.push(cb.unbind());
104+
}
105+
monitoring.call_method("set_events", (tool_id, mask.bits(), mask.bits()), None)?;
106+
107+
*guard = Some(Global { dispatcher: Dispatcher::new(tracer), tool_id, callbacks });
108+
Ok(())
109+
}
110+
111+
/// Remove the installed tracer if any.
112+
pub fn uninstall_tracer(py: Python<'_>) -> PyResult<()> {
113+
let mut guard = GLOBAL.lock().unwrap();
114+
if let Some(global) = guard.take() {
115+
let monitoring = py.import("sys")?.getattr("monitoring")?;
116+
let events = monitoring.getattr("events")?;
117+
if global.dispatcher.mask.contains(EventMask::CALL) {
118+
let ev = events.getattr("CALL")?;
119+
monitoring.call_method("register_callback", (global.tool_id, ev, py.None()), None)?;
120+
}
121+
if global.dispatcher.mask.contains(EventMask::LINE) {
122+
let ev = events.getattr("LINE")?;
123+
monitoring.call_method("register_callback", (global.tool_id, ev, py.None()), None)?;
124+
}
125+
monitoring.call_method("set_events", (global.tool_id, 0u32, global.dispatcher.mask.bits()), None)?;
126+
monitoring.call_method1("free_tool_id", (global.tool_id,))?;
127+
}
128+
Ok(())
129+
}
130+
131+
#[pyfunction]
132+
fn callback_call(py: Python<'_>, args: &Bound<'_, PyTuple>) -> PyResult<()> {
133+
let frame = args.get_item(0)?.as_ptr() as *mut ffi::PyFrameObject;
134+
if let Some(global) = GLOBAL.lock().unwrap().as_mut() {
135+
global.dispatcher.dispatch_call(py, frame);
136+
}
137+
Ok(())
138+
}
139+
140+
#[pyfunction]
141+
fn callback_line(py: Python<'_>, args: &Bound<'_, PyTuple>) -> PyResult<()> {
142+
let frame = args.get_item(0)?.as_ptr() as *mut ffi::PyFrameObject;
143+
let lineno: u32 = args.get_item(1)?.extract()?;
144+
if let Some(global) = GLOBAL.lock().unwrap().as_mut() {
145+
global.dispatcher.dispatch_line(py, frame, lineno);
146+
}
147+
Ok(())
148+
}
149+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use codetracer_python_recorder::{install_tracer, uninstall_tracer, EventMask, Tracer};
2+
use pyo3::prelude::*;
3+
use pyo3::ffi::PyFrameObject;
4+
use std::ffi::CString;
5+
6+
struct PrintTracer;
7+
8+
impl Tracer for PrintTracer {
9+
fn interest(&self) -> EventMask {
10+
EventMask::CALL
11+
}
12+
13+
fn on_call(&mut self, _py: Python<'_>, _frame: *mut PyFrameObject) {
14+
println!("call event");
15+
}
16+
}
17+
18+
#[test]
19+
fn tracer_prints_on_call() {
20+
Python::with_gil(|py| {
21+
install_tracer(py, Box::new(PrintTracer)).unwrap();
22+
let code = CString::new("def foo():\n return 1\nfoo()").unwrap();
23+
py.run(&code, None, None).unwrap();
24+
uninstall_tracer(py).unwrap();
25+
});
26+
}
27+

design-docs/design-001.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ The tracer collects `sys.monitoring` events, converts them to `runtime_tracing`
88

99
## Architecture
1010

11+
### Tracer Abstraction
12+
Rust code exposes a `Tracer` trait representing callbacks for Python
13+
`sys.monitoring` events. Implementations advertise their desired events via an
14+
`EventMask` bit flag returned from `interest`. A `Dispatcher` wraps a trait
15+
object and forwards events only when the mask contains the corresponding flag,
16+
allowing tracers to implement just the methods they care about.
17+
1118
### Tool Initialization
1219
- Acquire a tool identifier via `sys.monitoring.use_tool_id`; store it for the lifetime of the tracer.
1320
```rs

0 commit comments

Comments
 (0)