Skip to content

Commit cfe7434

Browse files
committed
WS5-1
codetracer-python-recorder/codetracer_python_recorder/auto_start.py: codetracer-python-recorder/codetracer_python_recorder/cli.py: codetracer-python-recorder/codetracer_python_recorder/session.py: codetracer-python-recorder/src/session/bootstrap.rs: codetracer-python-recorder/src/session.rs: codetracer-python-recorder/tests/python/test_cli_integration.py: codetracer-python-recorder/tests/python/unit/test_backend_exceptions.py: codetracer-python-recorder/tests/python/unit/test_cli.py: codetracer-python-recorder/tests/python/unit/test_session_helpers.py: design-docs/configurable-trace-filters-implementation-plan.status.md: Signed-off-by: Tzanko Matev <[email protected]>
1 parent f8cd31b commit cfe7434

File tree

10 files changed

+339
-20
lines changed

10 files changed

+339
-20
lines changed

codetracer-python-recorder/codetracer_python_recorder/auto_start.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
ENV_TRACE_PATH = "CODETRACER_TRACE"
1111
ENV_TRACE_FORMAT = "CODETRACER_FORMAT"
12+
ENV_TRACE_FILTER = "CODETRACER_TRACE_FILTER"
1213

1314
log = logging.getLogger(__name__)
1415

@@ -18,6 +19,7 @@ def auto_start_from_env() -> None:
1819
path = os.getenv(ENV_TRACE_PATH)
1920
if not path:
2021
return
22+
filter_spec = os.getenv(ENV_TRACE_FILTER)
2123

2224
# Delay import to avoid boot-time circular dependencies.
2325
from . import session
@@ -31,13 +33,15 @@ def auto_start_from_env() -> None:
3133

3234
fmt = os.getenv(ENV_TRACE_FORMAT, DEFAULT_FORMAT)
3335
log.debug(
34-
"codetracer auto-start triggered", extra={"trace_path": path, "format": fmt}
36+
"codetracer auto-start triggered",
37+
extra={"trace_path": path, "format": fmt, "trace_filter": filter_spec},
3538
)
36-
session.start(path, format=fmt)
39+
session.start(path, format=fmt, trace_filter=filter_spec)
3740

3841

3942
__all__: Iterable[str] = (
4043
"ENV_TRACE_FORMAT",
4144
"ENV_TRACE_PATH",
45+
"ENV_TRACE_FILTER",
4246
"auto_start_from_env",
4347
)

codetracer-python-recorder/codetracer_python_recorder/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class RecorderCLIConfig:
2323
activation_path: Path
2424
script: Path
2525
script_args: list[str]
26+
trace_filter: tuple[str, ...]
2627
policy_overrides: dict[str, object]
2728

2829

@@ -65,6 +66,14 @@ def _parse_args(argv: Sequence[str]) -> RecorderCLIConfig:
6566
"interpreter enters this file. Defaults to the target script."
6667
),
6768
)
69+
parser.add_argument(
70+
"--trace-filter",
71+
action="append",
72+
help=(
73+
"Path to a trace filter file. Provide multiple times to chain filters; "
74+
"specify multiple paths within a single argument using '//' separators."
75+
),
76+
)
6877
parser.add_argument(
6978
"--on-recorder-error",
7079
choices=["abort", "disable"],
@@ -174,6 +183,7 @@ def _parse_args(argv: Sequence[str]) -> RecorderCLIConfig:
174183
activation_path=activation_path,
175184
script=script_path,
176185
script_args=script_args,
186+
trace_filter=tuple(known.trace_filter or ()),
177187
policy_overrides=policy,
178188
)
179189

@@ -238,6 +248,7 @@ def main(argv: Iterable[str] | None = None) -> int:
238248
trace_dir = config.trace_dir
239249
script_path = config.script
240250
script_args = config.script_args
251+
filter_specs = list(config.trace_filter)
241252
policy_overrides = config.policy_overrides if config.policy_overrides else None
242253

243254
old_argv = sys.argv
@@ -248,6 +259,7 @@ def main(argv: Iterable[str] | None = None) -> int:
248259
trace_dir,
249260
format=config.format,
250261
start_on_enter=config.activation_path,
262+
trace_filter=filter_specs or None,
251263
policy=policy_overrides,
252264
)
253265
except Exception as exc:

codetracer-python-recorder/codetracer_python_recorder/session.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import contextlib
99
import os
10+
from collections.abc import Sequence
1011
from pathlib import Path
1112
from typing import Iterator, Mapping, Optional
1213

@@ -58,6 +59,7 @@ def start(
5859
*,
5960
format: str = DEFAULT_FORMAT,
6061
start_on_enter: str | Path | None = None,
62+
trace_filter: str | os.PathLike[str] | Sequence[str | os.PathLike[str]] | None = None,
6163
policy: Mapping[str, object] | None = None,
6264
apply_env_policy: bool = True,
6365
) -> TraceSession:
@@ -72,6 +74,10 @@ def start(
7274
start_on_enter:
7375
Optional path that delays trace activation until the interpreter enters
7476
the referenced file.
77+
trace_filter:
78+
Optional filter specification. Accepts a path-like object, an iterable
79+
of path-like objects, or a string containing ``//``-separated paths.
80+
Paths are expanded to absolute locations and must exist.
7581
policy:
7682
Optional mapping of runtime policy overrides forwarded to
7783
:func:`configure_policy` before tracing begins. Keys match the policy
@@ -102,13 +108,14 @@ def start(
102108
trace_path = _validate_trace_path(Path(path))
103109
normalized_format = _coerce_format(format)
104110
activation_path = _normalize_activation_path(start_on_enter)
111+
filter_chain = _normalize_trace_filter(trace_filter)
105112

106113
if apply_env_policy:
107114
_configure_policy_from_env()
108115
if policy:
109116
_configure_policy(**_coerce_policy_kwargs(policy))
110117

111-
_start_backend(str(trace_path), normalized_format, activation_path)
118+
_start_backend(str(trace_path), normalized_format, activation_path, filter_chain)
112119
session = TraceSession(path=trace_path, format=normalized_format)
113120
_active_session = session
114121
return session
@@ -139,13 +146,15 @@ def trace(
139146
path: str | Path,
140147
*,
141148
format: str = DEFAULT_FORMAT,
149+
trace_filter: str | os.PathLike[str] | Sequence[str | os.PathLike[str]] | None = None,
142150
policy: Mapping[str, object] | None = None,
143151
apply_env_policy: bool = True,
144152
) -> Iterator[TraceSession]:
145153
"""Context manager helper for scoped tracing."""
146154
session = start(
147155
path,
148156
format=format,
157+
trace_filter=trace_filter,
149158
policy=policy,
150159
apply_env_policy=apply_env_policy,
151160
)
@@ -178,6 +187,58 @@ def _normalize_activation_path(value: str | Path | None) -> str | None:
178187
return str(Path(value).expanduser())
179188

180189

190+
def _normalize_trace_filter(
191+
value: str | os.PathLike[str] | Sequence[str | os.PathLike[str]] | None,
192+
) -> list[str] | None:
193+
if value is None:
194+
return None
195+
196+
segments = _extract_filter_segments(value)
197+
if not segments:
198+
raise ValueError("trace_filter must resolve to at least one path")
199+
200+
resolved: list[str] = []
201+
for segment in segments:
202+
target = _resolve_trace_filter_path(segment)
203+
resolved.append(str(target))
204+
return resolved
205+
206+
207+
def _extract_filter_segments(
208+
value: str | os.PathLike[str] | Sequence[str | os.PathLike[str]],
209+
) -> list[str]:
210+
if isinstance(value, (str, os.PathLike)):
211+
return _split_filter_spec(os.fspath(value))
212+
213+
if isinstance(value, Sequence):
214+
segments: list[str] = []
215+
for item in value:
216+
if not isinstance(item, (str, os.PathLike)):
217+
raise TypeError(
218+
"trace_filter sequence entries must be str or os.PathLike"
219+
)
220+
segments.extend(_split_filter_spec(os.fspath(item)))
221+
return segments
222+
223+
raise TypeError("trace_filter must be a path, iterable of paths, or None")
224+
225+
226+
def _split_filter_spec(value: str) -> list[str]:
227+
parts = [segment.strip() for segment in value.split("//")]
228+
return [segment for segment in parts if segment]
229+
230+
231+
def _resolve_trace_filter_path(raw: str) -> Path:
232+
candidate = Path(raw).expanduser()
233+
if not candidate.exists():
234+
raise FileNotFoundError(f"trace filter '{candidate}' does not exist")
235+
236+
resolved = candidate.resolve()
237+
if not resolved.is_file():
238+
raise ValueError(f"trace filter '{resolved}' is not a file")
239+
return resolved
240+
241+
181242
def _coerce_policy_kwargs(policy: Mapping[str, object]) -> dict[str, object]:
182243
normalized: dict[str, object] = {}
183244
for key, raw_value in policy.items():

codetracer-python-recorder/src/session.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ use bootstrap::TraceSessionBootstrap;
1919
static ACTIVE: AtomicBool = AtomicBool::new(false);
2020

2121
/// Start tracing using sys.monitoring and runtime_tracing writer.
22-
#[pyfunction]
23-
pub fn start_tracing(path: &str, format: &str, activation_path: Option<&str>) -> PyResult<()> {
22+
#[pyfunction(signature = (path, format, activation_path=None, trace_filter=None))]
23+
pub fn start_tracing(
24+
path: &str,
25+
format: &str,
26+
activation_path: Option<&str>,
27+
trace_filter: Option<Vec<String>>,
28+
) -> PyResult<()> {
2429
ffi::wrap_pyfunction("start_tracing", || {
2530
// Ensure logging is ready before any tracer logs might be emitted.
2631
// Default our crate to warnings-only so tests stay quiet unless explicitly enabled.
@@ -33,13 +38,16 @@ pub fn start_tracing(path: &str, format: &str, activation_path: Option<&str>) ->
3338
}
3439

3540
let activation_path = activation_path.map(PathBuf::from);
41+
let filter_paths: Option<Vec<PathBuf>> =
42+
trace_filter.map(|items| items.into_iter().map(PathBuf::from).collect());
3643

3744
Python::with_gil(|py| {
3845
let bootstrap = TraceSessionBootstrap::prepare(
3946
py,
4047
Path::new(path),
4148
format,
4249
activation_path.as_deref(),
50+
filter_paths.as_ref().map(|paths| paths.as_slice()),
4351
)
4452
.map_err(ffi::map_recorder_error)?;
4553

codetracer-python-recorder/src/session/bootstrap.rs

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,15 @@ impl TraceSessionBootstrap {
5454
trace_directory: &Path,
5555
format: &str,
5656
activation_path: Option<&Path>,
57+
explicit_trace_filters: Option<&[PathBuf]>,
5758
) -> Result<Self> {
5859
ensure_trace_directory(trace_directory)?;
5960
let format = resolve_trace_format(format)?;
6061
let metadata = collect_program_metadata(py).map_err(|err| {
6162
enverr!(ErrorCode::Io, "failed to collect program metadata")
6263
.with_context("details", err.to_string())
6364
})?;
64-
let trace_filter = load_default_trace_filter(&metadata.program)?;
65+
let trace_filter = load_trace_filter(explicit_trace_filters, &metadata.program)?;
6566
Ok(Self {
6667
trace_directory: trace_directory.to_path_buf(),
6768
format,
@@ -158,14 +159,35 @@ pub fn collect_program_metadata(py: Python<'_>) -> PyResult<ProgramMetadata> {
158159
Ok(ProgramMetadata { program, args })
159160
}
160161

161-
fn load_default_trace_filter(program: &str) -> Result<Option<Arc<TraceFilterEngine>>> {
162+
fn load_trace_filter(
163+
explicit: Option<&[PathBuf]>,
164+
program: &str,
165+
) -> Result<Option<Arc<TraceFilterEngine>>> {
166+
let mut chain: Vec<PathBuf> = Vec::new();
167+
168+
if let Some(default) = discover_default_trace_filter(program)? {
169+
chain.push(default);
170+
}
171+
172+
if let Some(paths) = explicit {
173+
chain.extend(paths.iter().cloned());
174+
}
175+
176+
if chain.is_empty() {
177+
return Ok(None);
178+
}
179+
180+
let config = TraceFilterConfig::from_paths(&chain)?;
181+
Ok(Some(Arc::new(TraceFilterEngine::new(config))))
182+
}
183+
184+
fn discover_default_trace_filter(program: &str) -> Result<Option<PathBuf>> {
162185
let start_dir = resolve_program_directory(program)?;
163186
let mut current: Option<&Path> = Some(start_dir.as_path());
164187
while let Some(dir) = current {
165188
let candidate = dir.join(TRACE_FILTER_DIR).join(TRACE_FILTER_FILE);
166189
if matches!(fs::metadata(&candidate), Ok(metadata) if metadata.is_file()) {
167-
let config = TraceFilterConfig::from_paths(&[candidate.clone()])?;
168-
return Ok(Some(Arc::new(TraceFilterEngine::new(config))));
190+
return Ok(Some(candidate));
169191
}
170192
current = dir.parent();
171193
}
@@ -308,6 +330,7 @@ mod tests {
308330
trace_dir.as_path(),
309331
"json",
310332
Some(activation.as_path()),
333+
None,
311334
);
312335
sys.setattr("argv", original.bind(py))
313336
.expect("restore argv");
@@ -337,7 +360,7 @@ mod tests {
337360
sys.setattr("argv", argv).expect("set argv");
338361

339362
let result =
340-
TraceSessionBootstrap::prepare(py, trace_dir.as_path(), "json", None);
363+
TraceSessionBootstrap::prepare(py, trace_dir.as_path(), "json", None, None);
341364
sys.setattr("argv", original.bind(py))
342365
.expect("restore argv");
343366

@@ -386,7 +409,7 @@ mod tests {
386409
sys.setattr("argv", argv).expect("set argv");
387410

388411
let result =
389-
TraceSessionBootstrap::prepare(py, trace_dir.as_path(), "json", None);
412+
TraceSessionBootstrap::prepare(py, trace_dir.as_path(), "json", None, None);
390413
sys.setattr("argv", original.bind(py))
391414
.expect("restore argv");
392415

@@ -397,4 +420,83 @@ mod tests {
397420
assert_eq!(summary.entries[0].path, filter_path);
398421
});
399422
}
423+
424+
#[test]
425+
fn prepare_bootstrap_merges_explicit_trace_filters() {
426+
Python::with_gil(|py| {
427+
let project = tempdir().expect("project");
428+
let project_root = project.path();
429+
let trace_dir = project_root.join("out");
430+
431+
let app_dir = project_root.join("src");
432+
std::fs::create_dir_all(&app_dir).expect("create src dir");
433+
let script_path = app_dir.join("main.py");
434+
std::fs::write(&script_path, "print('run')\n").expect("write script");
435+
436+
let filters_dir = project_root.join(TRACE_FILTER_DIR);
437+
std::fs::create_dir(&filters_dir).expect("create filter dir");
438+
let default_filter_path = filters_dir.join(TRACE_FILTER_FILE);
439+
std::fs::write(
440+
&default_filter_path,
441+
r#"
442+
[meta]
443+
name = "default"
444+
version = 1
445+
446+
[scope]
447+
default_exec = "trace"
448+
default_value_action = "allow"
449+
450+
[[scope.rules]]
451+
selector = "pkg:src"
452+
exec = "trace"
453+
value_default = "allow"
454+
"#,
455+
)
456+
.expect("write default filter");
457+
458+
let override_filter_path = project_root.join("override-filter.toml");
459+
std::fs::write(
460+
&override_filter_path,
461+
r#"
462+
[meta]
463+
name = "override"
464+
version = 1
465+
466+
[scope]
467+
default_exec = "trace"
468+
default_value_action = "allow"
469+
470+
[[scope.rules]]
471+
selector = "pkg:src.special"
472+
exec = "skip"
473+
value_default = "deny"
474+
"#,
475+
)
476+
.expect("write override filter");
477+
478+
let sys = py.import("sys").expect("import sys");
479+
let original = sys.getattr("argv").expect("argv").unbind();
480+
let argv = PyList::new(py, [script_path.to_str().expect("utf8 path")]).expect("argv");
481+
sys.setattr("argv", argv).expect("set argv");
482+
483+
let explicit = vec![override_filter_path.clone()];
484+
let result = TraceSessionBootstrap::prepare(
485+
py,
486+
trace_dir.as_path(),
487+
"json",
488+
None,
489+
Some(explicit.as_slice()),
490+
);
491+
sys.setattr("argv", original.bind(py))
492+
.expect("restore argv");
493+
494+
let bootstrap = result.expect("bootstrap");
495+
let engine = bootstrap.trace_filter().expect("filter engine");
496+
let summary = engine.summary();
497+
assert_eq!(summary.entries.len(), 2);
498+
assert_eq!(summary.entries[0].path, default_filter_path);
499+
assert_eq!(summary.entries[1].path, override_filter_path);
500+
});
501+
}
400502
}

0 commit comments

Comments
 (0)