Skip to content

Commit 4ee12b2

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 e4f6c57 commit 4ee12b2

14 files changed

Lines changed: 309 additions & 102 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: 17 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);
@@ -760,6 +769,13 @@ pub struct FactCli {
760769
/// Default value is 0 (unlimited)
761770
#[arg(long, short = 'l', env = "FACT_RATE_LIMIT")]
762771
rate_limit: Option<u64>,
772+
773+
/// Replay events from a JSONL file instead of loading eBPF programs.
774+
///
775+
/// This mode skips BPF loading and HostScanner, reading pre-recorded
776+
/// events for profiling purposes (e.g. valgrind, DHAT).
777+
#[arg(long, env = "FACT_REPLAY")]
778+
replay: Option<PathBuf>,
763779
}
764780

765781
impl FactCli {
@@ -794,6 +810,7 @@ impl FactCli {
794810
hotreload: resolve_bool_arg(self.hotreload, self.no_hotreload),
795811
scan_interval: self.scan_interval,
796812
rate_limit: self.rate_limit,
813+
replay: self.replay.clone(),
797814
}
798815
}
799816
}

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ fn parsing() {
450450
hotreload: Some(false),
451451
scan_interval: Some(Duration::from_secs(60)),
452452
rate_limit: None,
453+
replay: None,
453454
},
454455
),
455456
];
@@ -1628,6 +1629,7 @@ fn update() {
16281629
hotreload: Some(true),
16291630
scan_interval: Some(Duration::from_secs(30)),
16301631
rate_limit: None,
1632+
replay: None,
16311633
},
16321634
FactConfig {
16331635
paths: Some(vec![PathBuf::from("/etc")]),
@@ -1659,6 +1661,7 @@ fn update() {
16591661
hotreload: Some(false),
16601662
scan_interval: Some(Duration::from_secs(60)),
16611663
rate_limit: None,
1664+
replay: None,
16621665
},
16631666
),
16641667
];

fact/src/event/mod.rs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::{
1111
use globset::GlobSet;
1212
#[cfg(feature = "otel")]
1313
use opentelemetry::logs::AnyValue;
14-
use serde::Serialize;
14+
use serde::{Deserialize, Serialize};
1515

1616
use fact_ebpf::{
1717
PATH_MAX, XATTR_NAME_MAX_LEN, event_t, file_activity_type_t, inode_key_t, monitored_t,
@@ -74,9 +74,10 @@ pub(crate) enum EventTestData {
7474
Rename(PathBuf),
7575
}
7676

77-
#[derive(Debug, Clone, Serialize)]
77+
#[derive(Debug, Clone, Serialize, Deserialize)]
7878
pub struct Event {
7979
timestamp: u64,
80+
#[serde(skip_deserializing)]
8081
hostname: &'static str,
8182
process: Process,
8283
file: FileData,
@@ -391,7 +392,7 @@ impl PartialEq for Event {
391392
}
392393
}
393394

394-
#[derive(Debug, Clone, Serialize)]
395+
#[derive(Debug, Clone, Serialize, Deserialize)]
395396
pub enum FileData {
396397
Open(BaseFileData),
397398
Creation(BaseFileData),
@@ -607,7 +608,7 @@ impl PartialEq for FileData {
607608
}
608609
}
609610

610-
#[derive(Debug, Clone, Serialize, Default)]
611+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
611612
pub struct BaseFileData {
612613
pub filename: PathBuf,
613614
host_file: PathBuf,
@@ -665,7 +666,7 @@ impl From<BaseFileData> for opentelemetry::logs::AnyValue {
665666
}
666667
}
667668

668-
#[derive(Debug, Clone, Serialize)]
669+
#[derive(Debug, Clone, Serialize, Deserialize)]
669670
pub struct ChmodFileData {
670671
inner: BaseFileData,
671672
new_mode: u16,
@@ -711,7 +712,7 @@ impl PartialEq for ChmodFileData {
711712
}
712713
}
713714

714-
#[derive(Debug, Clone, Serialize)]
715+
#[derive(Debug, Clone, Serialize, Deserialize)]
715716
pub struct ChownFileData {
716717
inner: BaseFileData,
717718
new_uid: u32,
@@ -767,7 +768,7 @@ impl From<ChownFileData> for opentelemetry::logs::AnyValue {
767768
}
768769
}
769770

770-
#[derive(Debug, Clone, Serialize)]
771+
#[derive(Debug, Clone, Serialize, Deserialize)]
771772
pub struct RenameFileData {
772773
new: BaseFileData,
773774
old: BaseFileData,
@@ -795,7 +796,7 @@ impl From<RenameFileData> for opentelemetry::logs::AnyValue {
795796
}
796797
}
797798

798-
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
799+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
799800
pub enum AclTag {
800801
UserObj,
801802
User,
@@ -842,7 +843,7 @@ impl AclTag {
842843
}
843844
}
844845

845-
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
846+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
846847
pub struct AclEntry {
847848
tag: AclTag,
848849
perm: u16,
@@ -876,7 +877,7 @@ impl From<AclEntry> for opentelemetry::logs::AnyValue {
876877
}
877878
}
878879

879-
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
880+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
880881
pub enum AclType {
881882
Access,
882883
Default,
@@ -892,7 +893,7 @@ impl From<AclType> for opentelemetry::logs::AnyValue {
892893
}
893894
}
894895

895-
#[derive(Debug, Clone, Serialize)]
896+
#[derive(Debug, Clone, Serialize, Deserialize)]
896897
pub struct AclSetFileData {
897898
inner: BaseFileData,
898899
acl_type: AclType,
@@ -968,7 +969,7 @@ impl PartialEq for RenameFileData {
968969
}
969970
}
970971

971-
#[derive(Debug, Clone, Serialize)]
972+
#[derive(Debug, Clone, Serialize, Deserialize)]
972973
pub struct XattrFileData {
973974
inner: BaseFileData,
974975
xattr_name: String,

fact/src/event/process.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ use std::{ffi::CStr, path::PathBuf};
55
use fact_ebpf::{lineage_t, process_t};
66
#[cfg(feature = "otel")]
77
use opentelemetry::logs::AnyValue;
8-
use serde::Serialize;
8+
use serde::{Deserialize, Serialize};
99
use uuid::Uuid;
1010

1111
use crate::host_info;
1212

1313
use super::{sanitize_d_path, slice_to_string};
1414

15-
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
15+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1616
pub struct Lineage {
1717
uid: u32,
1818
exe_path: PathBuf,
@@ -55,13 +55,14 @@ impl From<Lineage> for opentelemetry::logs::AnyValue {
5555
}
5656
}
5757

58-
#[derive(Debug, Clone, Default, Serialize)]
58+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5959
pub struct Process {
6060
comm: String,
6161
args: Vec<String>,
6262
exe_path: PathBuf,
6363
container_id: Option<String>,
6464
uid: u32,
65+
#[serde(skip_deserializing)]
6566
username: &'static str,
6667
gid: u32,
6768
login_uid: u32,

0 commit comments

Comments
 (0)