Skip to content

Commit 2f2e639

Browse files
committed
feat: add --replay mode for JSONL event replay without eBPF
Add a --replay <file> CLI flag that reads pre-recorded events from a JSONL file instead of loading eBPF programs. This allows profiling the entire userspace pipeline with tools like valgrind DHAT that don't support BPF syscalls. In replay mode, the BPF loader and HostScanner are bypassed entirely. Events flow directly from the JSONL reader through the RateLimiter and Output stages. Changes: - Add Deserialize impls for inode_key_t and monitored_t (fact-ebpf) - Add Deserialize derives to Event, FileData, Process and related types - Make kernel metrics optional in Exporter (None in replay mode) - Migrate task handles to a shared JoinSet for cleaner lifecycle mgmt - Add replay module with async JSONL file reader - Add --replay flag to CLI and config Assisted-by: `agent-mode` <noreply@opencode.ai>
1 parent 3a0f02e commit 2f2e639

16 files changed

Lines changed: 390 additions & 139 deletions

File tree

fact-ebpf/src/lib.rs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ use std::{error::Error, ffi::c_char, fmt::Display, hash::Hash, path::PathBuf};
44

55
use aya::{maps::lpm_trie, Pod};
66
use libc::memcpy;
7-
use serde::{ser::SerializeStruct, Serialize};
7+
use serde::{
8+
de::{self, MapAccess, Visitor},
9+
ser::SerializeStruct,
10+
Deserialize, Serialize,
11+
};
812

913
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
1014

@@ -101,6 +105,51 @@ impl Serialize for inode_key_t {
101105
}
102106
}
103107

108+
impl<'de> Deserialize<'de> for inode_key_t {
109+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
110+
where
111+
D: de::Deserializer<'de>,
112+
{
113+
struct InodeKeyVisitor;
114+
115+
impl<'de> Visitor<'de> for InodeKeyVisitor {
116+
type Value = inode_key_t;
117+
118+
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
119+
f.write_str("a struct with inode and dev fields")
120+
}
121+
122+
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
123+
where
124+
A: MapAccess<'de>,
125+
{
126+
let mut inode = None;
127+
let mut dev = None;
128+
while let Some(key) = map.next_key::<String>()? {
129+
match key.as_str() {
130+
"inode" => inode = Some(map.next_value()?),
131+
"dev" => dev = Some(map.next_value()?),
132+
_ => {
133+
map.next_value::<de::IgnoredAny>()?;
134+
}
135+
}
136+
}
137+
138+
let Some(inode) = inode else {
139+
return Err(de::Error::missing_field("inode"));
140+
};
141+
let Some(dev) = dev else {
142+
return Err(de::Error::missing_field("dev"));
143+
};
144+
145+
Ok(inode_key_t { inode, dev })
146+
}
147+
}
148+
149+
deserializer.deserialize_struct("inode_key_t", &["inode", "dev"], InodeKeyVisitor)
150+
}
151+
}
152+
104153
unsafe impl Pod for inode_key_t {}
105154

106155
impl Default for monitored_t {
@@ -124,6 +173,25 @@ impl Serialize for monitored_t {
124173
}
125174
}
126175

176+
impl<'de> Deserialize<'de> for monitored_t {
177+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
178+
where
179+
D: de::Deserializer<'de>,
180+
{
181+
let s = String::deserialize(deserializer)?;
182+
match s.as_str() {
183+
"not monitored" => Ok(monitored_t::NOT_MONITORED),
184+
"by inode" => Ok(monitored_t::MONITORED_BY_INODE),
185+
"by path" => Ok(monitored_t::MONITORED_BY_PATH),
186+
"by parent" => Ok(monitored_t::MONITORED_BY_PARENT),
187+
_ => Err(de::Error::unknown_variant(
188+
&s,
189+
&["not monitored", "by inode", "by path", "by parent"],
190+
)),
191+
}
192+
}
193+
}
194+
127195
impl metrics_by_hook_t {
128196
fn accumulate(mut self, other: &metrics_by_hook_t) -> metrics_by_hook_t {
129197
self.total += other.total;

fact/src/bpf/mod.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use log::{error, info, warn};
1313
use tokio::{
1414
io::unix::AsyncFd,
1515
sync::{mpsc, watch},
16-
task::JoinHandle,
16+
task::JoinSet,
1717
};
1818

1919
use crate::{config::BpfConfig, event::Event, host_info, metrics::EventCounter};
@@ -225,12 +225,13 @@ impl Bpf {
225225
// Gather events from the ring buffer and print them out.
226226
pub fn start(
227227
mut self,
228+
task_set: &mut JoinSet<anyhow::Result<()>>,
228229
mut running: watch::Receiver<bool>,
229230
event_counter: EventCounter,
230-
) -> JoinHandle<anyhow::Result<()>> {
231+
) {
231232
info!("Starting BPF worker...");
232233

233-
tokio::spawn(async move {
234+
task_set.spawn(async move {
234235
let rb = self.take_ringbuffer()?;
235236
let mut fd = AsyncFd::new(rb)?;
236237

@@ -283,7 +284,7 @@ impl Bpf {
283284
}
284285

285286
Ok(())
286-
})
287+
});
287288
}
288289
}
289290

@@ -322,9 +323,10 @@ mod bpf_tests {
322323
Bpf::new(reloader.paths(), &reloader.config().bpf).expect("Failed to load BPF code");
323324
let (run_tx, run_rx) = watch::channel(true);
324325
// Create a metrics exporter, but don't start it
325-
let exporter = Exporter::new(bpf.take_metrics().unwrap());
326+
let exporter = Exporter::new(Some(bpf.take_metrics().unwrap()));
327+
let mut task_set = JoinSet::new();
326328

327-
let handle = bpf.start(run_rx, exporter.metrics.bpf_worker.clone());
329+
bpf.start(&mut task_set, run_rx, exporter.metrics.bpf_worker.clone());
328330

329331
tokio::time::sleep(Duration::from_millis(500)).await;
330332

@@ -412,7 +414,7 @@ mod bpf_tests {
412414

413415
tokio::select! {
414416
res = wait => res.unwrap(),
415-
res = handle => res.unwrap().unwrap(),
417+
res = task_set.join_next() => res.unwrap().unwrap().unwrap(),
416418
}
417419

418420
run_tx.send(false).unwrap();

fact/src/config/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub struct FactConfig {
4242
hotreload: Option<bool>,
4343
scan_interval: Option<Duration>,
4444
rate_limit: Option<u64>,
45+
replay: Option<PathBuf>,
4546
}
4647

4748
impl FactConfig {
@@ -108,6 +109,10 @@ impl FactConfig {
108109
if let Some(rate_limit) = from.rate_limit {
109110
self.rate_limit = Some(rate_limit);
110111
}
112+
113+
if let Some(replay) = from.replay.as_deref() {
114+
self.replay = Some(replay.to_path_buf());
115+
}
111116
}
112117

113118
pub fn paths(&self) -> &[PathBuf] {
@@ -134,6 +139,10 @@ impl FactConfig {
134139
self.rate_limit.unwrap_or(0)
135140
}
136141

142+
pub fn replay(&self) -> Option<&Path> {
143+
self.replay.as_deref()
144+
}
145+
137146
#[cfg(test)]
138147
pub fn set_paths(&mut self, paths: Vec<PathBuf>) {
139148
self.paths = Some(paths);
@@ -247,6 +256,12 @@ impl TryFrom<Vec<Yaml>> for FactConfig {
247256
}
248257
config.rate_limit = Some(rate_limit as u64);
249258
}
259+
"replay" => {
260+
let Some(replay) = v.as_str() else {
261+
bail!("replay field has incorrect type: {v:?}")
262+
};
263+
config.replay = Some(PathBuf::from(replay));
264+
}
250265
name => bail!("Invalid field '{name}' with value: {v:?}"),
251266
}
252267
}
@@ -760,6 +775,13 @@ pub struct FactCli {
760775
/// Default value is 0 (unlimited)
761776
#[arg(long, short = 'l', env = "FACT_RATE_LIMIT")]
762777
rate_limit: Option<u64>,
778+
779+
/// Replay events from a JSONL file instead of loading eBPF programs.
780+
///
781+
/// This mode skips BPF loading and HostScanner, reading pre-recorded
782+
/// events for profiling purposes (e.g. valgrind, DHAT).
783+
#[arg(long, env = "FACT_REPLAY")]
784+
replay: Option<PathBuf>,
763785
}
764786

765787
impl FactCli {
@@ -794,6 +816,7 @@ impl FactCli {
794816
hotreload: resolve_bool_arg(self.hotreload, self.no_hotreload),
795817
scan_interval: self.scan_interval,
796818
rate_limit: self.rate_limit,
819+
replay: self.replay.clone(),
797820
}
798821
}
799822
}

fact/src/config/reloader.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use std::{
55
use log::{debug, info, warn};
66
use tokio::{
77
sync::{Notify, watch},
8-
task::JoinHandle,
98
time::interval,
109
};
1110

@@ -34,13 +33,13 @@ impl Reloader {
3433
///
3534
/// If hotreload is disabled on startup the task will not be
3635
/// spawned.
37-
pub fn start(mut self, mut running: watch::Receiver<bool>) -> Option<JoinHandle<()>> {
36+
pub fn start(mut self, mut running: watch::Receiver<bool>) {
3837
if !self.config.hotreload() {
3938
info!("Configuration hotreload is disabled, changes will require a restart.");
40-
return None;
39+
return;
4140
}
4241

43-
let handle = tokio::spawn(async move {
42+
tokio::spawn(async move {
4443
let mut ticker = interval(Duration::from_secs(10));
4544
loop {
4645
tokio::select! {
@@ -55,7 +54,6 @@ impl Reloader {
5554
}
5655
}
5756
});
58-
Some(handle)
5957
}
6058

6159
pub fn config(&self) -> &FactConfig {

fact/src/config/tests.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,13 @@ fn parsing() {
393393
..Default::default()
394394
},
395395
),
396+
(
397+
"replay: /some/path",
398+
FactConfig {
399+
replay: Some(PathBuf::from("/some/path")),
400+
..Default::default()
401+
},
402+
),
396403
(
397404
r#"
398405
paths:
@@ -420,6 +427,7 @@ fn parsing() {
420427
hotreload: false
421428
scan_interval: 60
422429
rate_limit: 50000
430+
replay: /some/path.jsonl
423431
"#,
424432
FactConfig {
425433
paths: Some(vec![PathBuf::from("/etc")]),
@@ -451,6 +459,7 @@ fn parsing() {
451459
hotreload: Some(false),
452460
scan_interval: Some(Duration::from_secs(60)),
453461
rate_limit: Some(50000),
462+
replay: Some(PathBuf::from("/some/path.jsonl")),
454463
},
455464
),
456465
];
@@ -790,6 +799,10 @@ paths:
790799
"rate_limit field has incorrect type: Real(\"1000.0\")",
791800
),
792801
("rate_limit: -1000", "invalid rate_limit: -1000"),
802+
(
803+
"replay: true",
804+
"replay field has incorrect type: Boolean(true)",
805+
),
793806
("unknown:", "Invalid field 'unknown' with value: Null"),
794807
];
795808
for (input, expected) in tests {
@@ -1600,6 +1613,25 @@ fn update() {
16001613
..Default::default()
16011614
},
16021615
),
1616+
(
1617+
"replay: /some/path.jsonl",
1618+
FactConfig::default(),
1619+
FactConfig {
1620+
replay: Some("/some/path.jsonl".into()),
1621+
..Default::default()
1622+
},
1623+
),
1624+
(
1625+
"replay: /some/path.jsonl",
1626+
FactConfig {
1627+
replay: Some("/initial/path.jsonl".into()),
1628+
..Default::default()
1629+
},
1630+
FactConfig {
1631+
replay: Some("/some/path.jsonl".into()),
1632+
..Default::default()
1633+
},
1634+
),
16031635
(
16041636
r#"
16051637
paths:
@@ -1658,6 +1690,7 @@ fn update() {
16581690
hotreload: Some(true),
16591691
scan_interval: Some(Duration::from_secs(30)),
16601692
rate_limit: Some(5000),
1693+
replay: None,
16611694
},
16621695
FactConfig {
16631696
paths: Some(vec![PathBuf::from("/etc")]),
@@ -1689,6 +1722,7 @@ fn update() {
16891722
hotreload: Some(false),
16901723
scan_interval: Some(Duration::from_secs(60)),
16911724
rate_limit: Some(1000),
1725+
replay: None,
16921726
},
16931727
),
16941728
];
@@ -1728,6 +1762,7 @@ fn defaults() {
17281762
assert_eq!(config.otel.endpoint(), None);
17291763
assert_eq!(config.scan_interval(), Duration::from_secs(30));
17301764
assert_eq!(config.rate_limit(), 0);
1765+
assert!(config.replay().is_none());
17311766
}
17321767

17331768
static ENV_MUTEX: Mutex<()> = Mutex::new(());

0 commit comments

Comments
 (0)