Skip to content

Commit bcba3ec

Browse files
RoyLinRoyLin
authored andcommitted
release: v0.8.0 — process lifecycle (ProcessExit + exit code)
sys_enter_exit_group → AgentEvent::ProcessExit {pid, exit_code}, pairing with ToolExec to bracket a tool's lifecycle — you see outcomes (success/failure), not just that it ran. Host-validated: exit 42 → {"exit_code":42}. Folds in the exec-ring headroom + fileguard clippy fix. 0.7.0 -> 0.8.0.
1 parent bd8ff8b commit bcba3ec

12 files changed

Lines changed: 88 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
All notable changes to a3s-observer will be documented in this file.
44

5+
## [0.8.0] — process lifecycle (exit + exit code)
6+
7+
### Added
8+
9+
- **`ProcessExit` event**`sys_enter_exit_group` captures a process's exit + **exit code**,
10+
pairing with `ToolExec` to bracket a tool's lifecycle (started → finished with this status).
11+
You now see tool *outcomes* (did the command succeed / fail?), not just that it ran.
12+
Host-validated: `exit 42` surfaced `{"exit_code":42}`. Catches clean exits (the C runtime's
13+
`exit()` calls `exit_group`); signal kills don't.
14+
15+
### Changed
16+
17+
- Exec ring 256→512 KiB — the v0.7.0 argv buffer made `ExecEvent` ~928 B (6×), which had
18+
halved the exec ring; restore headroom so a process burst doesn't drop. fileguard clippy-clean.
19+
520
## [0.7.0] — richer tool observability (full argv + cwd)
621

722
### Added / Changed

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer"
3-
version = "0.7.0"
3+
version = "0.8.0"
44
edition = "2021"
55
license = "MIT"
66
description = "General-purpose, language-agnostic eBPF observability for AI agents (LLM calls, tools, files, network egress)."

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ latency / TTFT, or plaintext) / **where** (peer IP / hostname).
4444
| signal | kernel hook | event |
4545
|---|---|---|
4646
| `exec` | `sys_enter_execve` | `ToolExec` — tool / subprocess: **full argv** + cwd, comm, uid |
47+
| `exit` | `sys_enter_exit_group` | `ProcessExit` — tool outcome + **exit code** (clean exits) |
4748
| `connect` | `sys_enter_connect` | `Egress` — peer IP:port |
4849
| `sni` | TLS ClientHello (plaintext `server_name`) | LLM **provider** + endpoint |
4950
| `dns` | `sendto` / `sendmsg` / `sendmmsg` to :53 | `Dns` — resolved hostname |

a3s-observer-collector/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-collector"
3-
version = "0.7.0"
3+
version = "0.8.0"
44
edition = "2021"
55
license = "MIT"
66
description = "a3s-observer collector: loads the eBPF probes and exports enriched events."

a3s-observer-collector/src/main.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use a3s_observer::{
1111
KubeResolver, LogExporter, Provider, ServiceClassifier, SniClassifier,
1212
};
1313
use a3s_observer_common::{
14-
ConnectEvent, DnsEvent, ExecEvent, FileEvent, LlmEvent, SslEvent, TlsEvent,
14+
ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SslEvent, TlsEvent,
1515
};
1616
use anyhow::Context as _;
1717
use aya::{
@@ -57,6 +57,7 @@ async fn main() -> anyhow::Result<()> {
5757
let files = std::env::var_os("A3S_OBSERVER_FILES").is_some();
5858
let mut probes = vec![
5959
("exec", "sys_enter_execve"),
60+
("proc_exit", "sys_enter_exit_group"),
6061
("tls_write", "sys_enter_write"),
6162
("tls_sendto", "sys_enter_sendto"),
6263
("connect", "sys_enter_connect"),
@@ -129,6 +130,10 @@ async fn main() -> anyhow::Result<()> {
129130
// closes (the in-kernel LlmEvent) to build the metric-bearing LlmCall.
130131
let mut llm_meta: HashMap<u64, (Option<String>, Option<Provider>, IpAddr)> = HashMap::new();
131132
let mut exec_ring = RingBuf::try_from(ebpf.take_map("EVENTS").context("`EVENTS` missing")?)?;
133+
let mut exit_ring = RingBuf::try_from(
134+
ebpf.take_map("EXIT_EVENTS")
135+
.context("`EXIT_EVENTS` missing")?,
136+
)?;
132137
let mut tls_ring = RingBuf::try_from(
133138
ebpf.take_map("TLS_EVENTS")
134139
.context("`TLS_EVENTS` missing")?,
@@ -194,6 +199,7 @@ async fn main() -> anyhow::Result<()> {
194199
.unwrap_or(0);
195200
tracing::info!(
196201
exec = stats.exec,
202+
exit = stats.exit,
197203
egress = stats.egress,
198204
dns = stats.dns,
199205
file = stats.file,
@@ -222,6 +228,18 @@ async fn main() -> anyhow::Result<()> {
222228
});
223229
}
224230
}
231+
while let Some(item) = exit_ring.next() {
232+
if let Some(ev) = read_pod::<ExitEvent>(&item) {
233+
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
234+
identity: identity_for(&resolver, ev.pid, &ev.comm),
235+
provider: None,
236+
event: AgentEvent::ProcessExit {
237+
pid: ev.pid,
238+
exit_code: ev.exit_code,
239+
},
240+
});
241+
}
242+
}
225243
// Drain connect BEFORE tls so a same-poll ClientHello finds its peer.
226244
while let Some(item) = connect_ring.next() {
227245
if let Some(ev) = read_pod::<ConnectEvent>(&item) {
@@ -347,6 +365,7 @@ async fn main() -> anyhow::Result<()> {
347365
}
348366
tracing::info!(
349367
exec = stats.exec,
368+
exit = stats.exit,
350369
egress = stats.egress,
351370
dns = stats.dns,
352371
file = stats.file,
@@ -432,6 +451,7 @@ fn identity_for(r: &impl IdentityResolver, pid: u32, comm: &[u8; 16]) -> Identit
432451
#[derive(Default)]
433452
struct Stats {
434453
exec: u64,
454+
exit: u64,
435455
egress: u64,
436456
dns: u64,
437457
file: u64,
@@ -443,6 +463,7 @@ struct Stats {
443463
fn emit(exporter: &dyn Exporter, stats: &mut Stats, ev: EnrichedEvent) {
444464
match &ev.event {
445465
AgentEvent::ToolExec { .. } => stats.exec += 1,
466+
AgentEvent::ProcessExit { .. } => stats.exit += 1,
446467
AgentEvent::Egress { .. } => stats.egress += 1,
447468
AgentEvent::Dns { .. } => stats.dns += 1,
448469
AgentEvent::FileAccess { .. } => stats.file += 1,

a3s-observer-common/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-common"
3-
version = "0.7.0"
3+
version = "0.8.0"
44
edition = "2021"
55
license = "MIT"
66
description = "Shared no_std types crossing the eBPF <-> userspace boundary for a3s-observer."

a3s-observer-common/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ pub struct ExecEvent {
2525
pub args: [[u8; ARG_LEN]; ARGV_SLOTS], // argv[0..argc], each NUL-terminated
2626
}
2727

28+
/// A process exit (`sys_enter_exit_group`) — the other end of the tool lifecycle, carrying the
29+
/// exit status so tool *outcomes* are visible (did the command succeed?), not just that it ran.
30+
/// Catches clean exits (the C runtime's `exit()` calls `exit_group`); signal kills don't.
31+
#[repr(C)]
32+
#[derive(Clone, Copy)]
33+
pub struct ExitEvent {
34+
pub pid: u32,
35+
pub exit_code: u32, // exit_group status; the low byte is the code passed to exit()
36+
pub comm: [u8; 16],
37+
}
38+
2839
/// The leading bytes of an outbound TLS ClientHello, captured at the send syscall.
2940
///
3041
/// The eBPF side only detects + copies (verifier-friendly); userspace parses the SNI

a3s-observer-ebpf/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-ebpf"
3-
version = "0.7.0"
3+
version = "0.8.0"
44
edition = "2021"
55
license = "MIT"
66
publish = false

a3s-observer-ebpf/src/main.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
#![no_main]
33

44
use a3s_observer_common::{
5-
ConnectEvent, DnsEvent, ExecEvent, FileEvent, LlmEvent, SslEvent, TlsEvent, ARGV_SLOTS,
6-
ARG_LEN, DNS_SNAP_LEN, PATH_SNAP_LEN, SSL_SNAP_LEN, TLS_SNAP_LEN,
5+
ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SslEvent, TlsEvent,
6+
ARGV_SLOTS, ARG_LEN, DNS_SNAP_LEN, PATH_SNAP_LEN, SSL_SNAP_LEN, TLS_SNAP_LEN,
77
};
88
use aya_ebpf::{
99
helpers::gen::bpf_probe_read_user,
@@ -21,6 +21,9 @@ use aya_ebpf::{
2121
#[map]
2222
static EVENTS: RingBuf = RingBuf::with_byte_size(512 * 1024, 0);
2323

24+
#[map]
25+
static EXIT_EVENTS: RingBuf = RingBuf::with_byte_size(64 * 1024, 0);
26+
2427
#[map]
2528
static TLS_EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);
2629

@@ -145,6 +148,28 @@ fn try_exec(ctx: &TracePointContext) -> Result<u32, i64> {
145148
Ok(0)
146149
}
147150

151+
// ---- process exit (sys_enter_exit_group) — the tool's outcome / exit code ----
152+
153+
#[tracepoint]
154+
pub fn proc_exit(ctx: TracePointContext) -> u32 {
155+
try_proc_exit(&ctx).unwrap_or(0)
156+
}
157+
158+
fn try_proc_exit(ctx: &TracePointContext) -> Result<u32, i64> {
159+
let Some(mut entry) = reserve_or_drop::<ExitEvent>(&EXIT_EVENTS) else {
160+
return Ok(0);
161+
};
162+
let ev = entry.as_mut_ptr();
163+
unsafe {
164+
(*ev).pid = (bpf_get_current_pid_tgid() >> 32) as u32;
165+
(*ev).comm = bpf_get_current_comm().unwrap_or_default();
166+
// sys_enter_exit_group: `long error_code` at offset 16 (low byte = the exit() code).
167+
(*ev).exit_code = ctx.read_at::<u64>(16).unwrap_or(0) as u32;
168+
}
169+
entry.submit(0);
170+
Ok(0)
171+
}
172+
148173
// ---- TLS ClientHello on send (sys_enter_write / sys_enter_sendto) ----
149174
//
150175
// Both tracepoints share arg layout: buf @ offset 24, count @ offset 32. The probe only

0 commit comments

Comments
 (0)