Skip to content

Commit 73d6642

Browse files
ZhiXiao-LinRoyLin
andauthored
feat: richer signals — exit-signal, LLM model, dest-port, uid (v0.10.0) (#2)
- ProcessExit gains signal (do_exit kprobe, thread-group-leader gated -> one event per process): clean / SIGSEGV crash / SIGKILL-OOM, which the old exit_group tracepoint never saw. - LlmApi {model, prompt_tokens, completion_tokens} parsed (userspace) from opt-in TLS content. - Egress gains dest port (service class); ToolExec gains uid (root/privesc) — both were already read in-kernel and discarded (free wins). - build.rs rerun-if-changed on the ebpf crate (no more stale bytecode). Validated live on the prod cluster; adversarial fan-out caught + fixed a per-thread duplication regression + a broken test before release. 0.9.3 -> 0.10.0. Co-authored-by: RoyLin <roylin@RoyLindeMacBook-Pro.local>
1 parent c5a0e4b commit 73d6642

14 files changed

Lines changed: 169 additions & 31 deletions

File tree

CHANGELOG.md

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

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

5+
## [0.10.0] — richer signals: exit-signal, LLM model, dest-port, uid
6+
7+
### Added
8+
9+
- **Exit signal**`ProcessExit` now carries `signal` (0 clean / 9 SIGKILL+OOM / 11 SIGSEGV
10+
crash). The probe moved from the `sys_enter_exit_group` tracepoint to a `do_exit` kprobe, so
11+
crashes and signal-kills — which the tracepoint never saw — are captured. Gated to the
12+
thread-group leader: one event per process, not per thread.
13+
- **LLM model + tokens**`AgentEvent::LlmApi {model, prompt_tokens, completion_tokens}`, parsed
14+
in userspace from the opt-in TLS content: which model the agent called + token usage. Pairs
15+
with the raw `SslContent`.
16+
- **Destination port on `Egress`** — the service class the agent dials (443 API / 22 SSH / 5432
17+
Postgres / 6379 Redis / 11434 Ollama). The port was already read in-kernel and discarded.
18+
- **UID on `ToolExec`** — the real UID a tool runs as (0 = root): privilege / privesc visibility.
19+
20+
### Fixed
21+
22+
- `build.rs` now emits `rerun-if-changed` for the eBPF crate, so a probe-source-only change no
23+
longer reuses stale bytecode.
24+
25+
### Tested
26+
27+
- Each signal validated live on the production cluster (crash/kill/OOM; LlmApi model over TLS;
28+
port 6443/etcd/redis; uid root/service/nobody). An adversarial fan-out review caught + fixed a
29+
per-thread `ProcessExit` duplication regression (multithreaded agents) before release; the
30+
untrusted-input LLM parser passed a 50M-iteration fuzz.
31+
532
## [0.9.3] — soak validation + test coverage (no runtime change)
633

734
### Tested

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.9.3"
3+
version = "0.10.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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,15 @@ 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) |
47+
| `exit` | `do_exit` kprobe | `ProcessExit` — outcome: **exit code + signal** (clean / SIGSEGV crash / SIGKILL-OOM), one per process |
4848
| `connect` | `sys_enter_connect` | `Egress` — peer IP:port |
4949
| `sni` | TLS ClientHello (plaintext `server_name`) | LLM **provider** + endpoint |
5050
| `dns` | `sendto` / `sendmsg` / `sendmmsg` to :53 | `Dns` — resolved hostname |
5151
| llm metrics | per-socket `read`/`recv` + `close` | `LlmCall` — req/resp wire bytes, latency, TTFT |
5252
| `file`\* | `sys_enter_openat` (write opens) | `FileAccess` — files written (`A3S_OBSERVER_FILES=1`) |
5353
| `unlink`\* | `sys_enter_unlinkat` | `FileDelete` — files deleted (`A3S_OBSERVER_FILES=1`) |
5454
| `ssl`\* | OpenSSL `SSL_write` / `SSL_read` uprobes | `SslContent` — request/response plaintext (`A3S_OBSERVER_SSL=1`) |
55+
| `llm-api`\* | parsed from `SslContent` | `LlmApi`**model** + token usage (`A3S_OBSERVER_SSL=1`) |
5556

5657
Userspace enriches each event with **identity** (k8s cgroup→pod, `/proc` comm+ppid, or an
5758
in-kernel `comm` fallback for short-lived processes), a `(pid,fd)→peer` **correlation**, and

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.9.3"
3+
version = "0.10.0"
44
edition = "2021"
55
license = "MIT"
66
description = "a3s-observer collector: loads the eBPF probes and exports enriched events."

a3s-observer-collector/build.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ use aya_build::{Package, Toolchain};
66
fn main() -> anyhow::Result<()> {
77
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
88
let ebpf_dir = format!("{manifest_dir}/../a3s-observer-ebpf");
9+
// aya_build doesn't emit rerun-if for the eBPF crate, so a source-only change to the probes
10+
// would silently reuse stale bytecode. Track it explicitly.
11+
println!("cargo:rerun-if-changed={ebpf_dir}/src");
12+
println!("cargo:rerun-if-changed={ebpf_dir}/Cargo.toml");
913
aya_build::build_ebpf(
1014
[Package {
1115
name: "a3s-observer-ebpf",

a3s-observer-collector/src/main.rs

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use a3s_observer_common::{
1717
use anyhow::Context as _;
1818
use aya::{
1919
maps::{PerCpuArray, RingBuf},
20-
programs::{TracePoint, UProbe},
20+
programs::{KProbe, TracePoint, UProbe},
2121
Ebpf,
2222
};
2323
use std::collections::HashMap;
@@ -63,7 +63,6 @@ async fn main() -> anyhow::Result<()> {
6363
let files = std::env::var_os("A3S_OBSERVER_FILES").is_some();
6464
let mut probes = vec![
6565
("exec", "sys_enter_execve"),
66-
("proc_exit", "sys_enter_exit_group"),
6766
("tls_write", "sys_enter_write"),
6867
("tls_sendto", "sys_enter_sendto"),
6968
("connect", "sys_enter_connect"),
@@ -91,6 +90,14 @@ async fn main() -> anyhow::Result<()> {
9190
}
9291
}
9392
}
93+
// proc_exit is a do_exit kprobe (not a tracepoint): do_exit fires for EVERY task exit,
94+
// including signal-kills (crash / OOM) that sys_enter_exit_group never sees.
95+
match attach_kprobe(&mut ebpf, "proc_exit", "do_exit") {
96+
Ok(()) => attached += 1,
97+
Err(e) => {
98+
tracing::warn!(error = %e, "proc_exit (do_exit kprobe) failed — exit signals unavailable")
99+
}
100+
}
94101
if attached == 0 {
95102
anyhow::bail!("no eBPF probes could be attached");
96103
}
@@ -132,7 +139,7 @@ async fn main() -> anyhow::Result<()> {
132139
let classifier = SniClassifier;
133140
let resolver = KubeResolver; // cgroup→pod in k8s; falls back to comm on bare hosts
134141
// (pid,fd) -> peer, populated by connect, read by the TLS probe to fuse provider+peer.
135-
let mut peers: HashMap<u64, IpAddr> = HashMap::new();
142+
let mut peers: HashMap<u64, (IpAddr, u16)> = HashMap::new();
136143
// (pid,fd) -> (sni, provider, peer): recorded at ClientHello, read when the socket
137144
// closes (the in-kernel LlmEvent) to build the metric-bearing LlmCall.
138145
let mut llm_meta: HashMap<u64, (Option<String>, Option<Provider>, IpAddr)> = HashMap::new();
@@ -232,6 +239,7 @@ async fn main() -> anyhow::Result<()> {
232239
event: AgentEvent::ToolExec {
233240
pid: ev.pid,
234241
ppid: read_ppid(ev.pid),
242+
uid: ev.uid,
235243
argv: argv_of(&ev),
236244
cwd: read_cwd(ev.pid),
237245
},
@@ -246,6 +254,7 @@ async fn main() -> anyhow::Result<()> {
246254
event: AgentEvent::ProcessExit {
247255
pid: ev.pid,
248256
exit_code: ev.exit_code,
257+
signal: ev.signal,
249258
},
250259
});
251260
}
@@ -257,14 +266,15 @@ async fn main() -> anyhow::Result<()> {
257266
if peers.len() > 8192 {
258267
peers.clear(); // ponytail: crude cap; LRU if it ever matters
259268
}
260-
peers.insert(sock_key(ev.pid, ev.fd), peer);
269+
peers.insert(sock_key(ev.pid, ev.fd), (peer, ev.port));
261270
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
262271
identity: identity_for(&resolver, ev.pid, &ev.comm),
263272
provider: None,
264273
event: AgentEvent::Egress {
265274
pid: ev.pid,
266275
sni: None,
267276
peer,
277+
port: ev.port,
268278
bytes: 0,
269279
},
270280
});
@@ -275,10 +285,10 @@ async fn main() -> anyhow::Result<()> {
275285
let len = (ev.len as usize).min(ev.data.len());
276286
let sni = parse_sni(&ev.data[..len]);
277287
// Correlated peer for this socket (the LLM endpoint).
278-
let peer = peers
288+
let (peer, port) = peers
279289
.get(&sock_key(ev.pid, ev.fd))
280290
.copied()
281-
.unwrap_or(UNKNOWN_PEER);
291+
.unwrap_or((UNKNOWN_PEER, 0));
282292
let provider =
283293
sni.as_deref().and_then(|h| classifier.classify(Some(h), peer));
284294
// Remember the call so the close event can build a metric-bearing
@@ -294,6 +304,7 @@ async fn main() -> anyhow::Result<()> {
294304
pid: ev.pid,
295305
sni,
296306
peer,
307+
port,
297308
bytes: ev.len as u64,
298309
},
299310
});
@@ -364,8 +375,25 @@ async fn main() -> anyhow::Result<()> {
364375
let len = (ev.len as usize).min(ev.data.len());
365376
let content = String::from_utf8_lossy(&ev.data[..len]).into_owned();
366377
if !content.is_empty() {
378+
let identity = identity_for(&resolver, ev.pid, &ev.comm);
379+
// Structured LLM telemetry (model/tokens) alongside the raw content.
380+
if let Some((model, prompt_tokens, completion_tokens)) =
381+
parse_llm_meta(&content)
382+
{
383+
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
384+
identity: identity.clone(),
385+
provider: None,
386+
event: AgentEvent::LlmApi {
387+
pid: ev.pid,
388+
is_request: ev.is_read == 0,
389+
model,
390+
prompt_tokens,
391+
completion_tokens,
392+
},
393+
});
394+
}
367395
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
368-
identity: identity_for(&resolver, ev.pid, &ev.comm),
396+
identity,
369397
provider: None,
370398
event: AgentEvent::SslContent {
371399
pid: ev.pid,
@@ -406,6 +434,17 @@ fn attach(ebpf: &mut Ebpf, prog: &str, category: &str, name: &str) -> anyhow::Re
406434
Ok(())
407435
}
408436

437+
fn attach_kprobe(ebpf: &mut Ebpf, prog: &str, sym: &str) -> anyhow::Result<()> {
438+
let p: &mut KProbe = ebpf
439+
.program_mut(prog)
440+
.with_context(|| format!("`{prog}` program not found"))?
441+
.try_into()?;
442+
p.load()?;
443+
p.attach(sym, 0)
444+
.with_context(|| format!("attach kprobe {sym}"))?;
445+
Ok(())
446+
}
447+
409448
fn attach_uprobe(ebpf: &mut Ebpf, prog: &str, sym: &str, target: &str) -> anyhow::Result<()> {
410449
let p: &mut UProbe = ebpf
411450
.program_mut(prog)
@@ -486,6 +525,7 @@ fn emit(exporter: &dyn Exporter, stats: &mut Stats, ev: EnrichedEvent) {
486525
AgentEvent::FileDelete { .. } => stats.file += 1,
487526
AgentEvent::LlmCall { .. } => stats.llm += 1,
488527
AgentEvent::SslContent { .. } => stats.ssl += 1,
528+
AgentEvent::LlmApi { .. } => stats.llm += 1,
489529
}
490530
exporter.export(&ev);
491531
}
@@ -560,9 +600,33 @@ fn parse_dns_qname(buf: &[u8]) -> Option<String> {
560600
(!name.is_empty()).then_some(name)
561601
}
562602

603+
/// Best-effort LLM-API fields from captured TLS plaintext: `"model"` from a request body, token
604+
/// `usage` from a response. None if absent (not an LLM call, or the bytes weren't captured).
605+
/// Consumes untrusted plaintext — every index is bounds-checked, must never panic.
606+
fn parse_llm_meta(s: &str) -> Option<(Option<String>, Option<u32>, Option<u32>)> {
607+
let model = json_str_after(s, "\"model\"");
608+
let pt = json_num_after(s, "\"prompt_tokens\"");
609+
let ct = json_num_after(s, "\"completion_tokens\"");
610+
(model.is_some() || pt.is_some() || ct.is_some()).then_some((model, pt, ct))
611+
}
612+
613+
fn json_str_after(s: &str, key: &str) -> Option<String> {
614+
let rest = &s[s.find(key)? + key.len()..]; // find() ≤ len, +key.len() ≤ len → in-bounds
615+
let body = &rest[rest.find('"')? + 1..]; // past the value's opening quote
616+
Some(body[..body.find('"')?].to_owned())
617+
}
618+
619+
fn json_num_after(s: &str, key: &str) -> Option<u32> {
620+
let rest = s[s.find(key)? + key.len()..].trim_start_matches([':', ' ', '\t']);
621+
let end = rest
622+
.find(|c: char| !c.is_ascii_digit())
623+
.unwrap_or(rest.len());
624+
rest.get(..end)?.parse().ok()
625+
}
626+
563627
#[cfg(test)]
564628
mod tests {
565-
use super::{parse_dns_qname, parse_sni};
629+
use super::{parse_dns_qname, parse_llm_meta, parse_sni};
566630

567631
#[test]
568632
fn parses_sni_from_minimal_clienthello() {
@@ -589,6 +653,16 @@ mod tests {
589653
assert_eq!(parse_sni(&[]), None);
590654
}
591655

656+
#[test]
657+
fn parse_llm_meta_extracts_model_and_tokens() {
658+
let req = r#"POST /v1/chat/completions HTTP/1.1 ... {"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}"#;
659+
assert_eq!(parse_llm_meta(req).unwrap().0.as_deref(), Some("gpt-4o"));
660+
let resp = r#"{"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":34}}"#;
661+
let (_, pt, ct) = parse_llm_meta(resp).unwrap();
662+
assert_eq!((pt, ct), (Some(12), Some(34)));
663+
assert!(parse_llm_meta("just plaintext, no json fields here").is_none());
664+
}
665+
592666
#[test]
593667
fn parses_dns_query_name() {
594668
let mut q = vec![0u8; 12]; // header

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.9.3"
3+
version = "0.10.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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ pub struct ExecEvent {
2727

2828
/// A process exit (`sys_enter_exit_group`) — the other end of the tool lifecycle, carrying the
2929
/// 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.
30+
/// Captured via a `do_exit` kprobe, so it catches EVERY exit — clean exits and signal-kills
31+
/// (crash / SIGKILL / OOM) alike.
3132
#[repr(C)]
3233
#[derive(Clone, Copy)]
3334
pub struct ExitEvent {
3435
pub pid: u32,
35-
pub exit_code: u32, // exit_group status; the low byte is the code passed to exit()
36+
pub exit_code: u32, // exit() status (0 when terminated by a signal)
37+
pub signal: u32, // terminating signal, 0 = clean exit (9 SIGKILL/OOM, 11 SIGSEGV crash, …)
3638
pub comm: [u8; 16],
3739
}
3840

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.9.3"
3+
version = "0.10.0"
44
edition = "2021"
55
license = "MIT"
66
publish = false

0 commit comments

Comments
 (0)