Skip to content

Commit 4e2db38

Browse files
RoyLinRoyLin
authored andcommitted
feat: emit collector heartbeat telemetry
1 parent b1deb73 commit 4e2db38

3 files changed

Lines changed: 157 additions & 1 deletion

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@ latency / TTFT, or plaintext) / **where** (peer IP / hostname).
5454
| `ssl`\* | OpenSSL `SSL_write` / `SSL_read` uprobes | `SslContent` — request/response plaintext (`A3S_OBSERVER_SSL=1`) |
5555
| `llm-api`\* | parsed from `SslContent` | `LlmApi`**model** + token usage (`A3S_OBSERVER_SSL=1`) |
5656
| `security` | `setuid` / `ptrace` / `bind` syscalls | `SecurityAction` — privilege escalation (→root) / process injection / opened a listening port (rare + in-kernel-filtered) |
57+
| collector heartbeat | userspace timer | `CollectorHeartbeat` — collector id, node/pod, attached probes, feature flags, per-window counts, ring drops, output drops |
5758

5859
Userspace enriches each event with **identity** (k8s cgroup→pod, `/proc` comm+ppid, or an
5960
in-kernel `comm` fallback for short-lived processes), a `(pid,fd)→peer` **correlation**, and
6061
**provider** classification (SNI → 15 LLM providers); then exports **NDJSON** (or a human log).
62+
`CollectorHeartbeat` is a control-plane event for platforms such as AnySentry; it is not an
63+
agent action and should not be fed into security policy decisions.
6164

6265
**Example output** (`A3S_OBSERVER_JSON=1`, one event per line — wrapped here for readability):
6366

@@ -77,6 +80,9 @@ in-kernel `comm` fallback for short-lived processes), a `(pid,fd)→peer` **corr
7780
Filter with `jq`, e.g. every LLM call and its provider:
7881
`… | jq -c 'select(.event.LlmCall) | {agent:.identity.agent, provider, sni:.event.LlmCall.sni}'`.
7982

83+
Set `A3S_OBSERVER_COLLECTOR_ID` and `A3S_NODE_NAME` in DaemonSets when you want stable collector
84+
identity in downstream fleet-health views. If unset, the collector falls back to pod/host names.
85+
8086
## Intervene — egress / file / exec (opt-in)
8187

8288
The same vantage point enforces an **external policy** — a plain file any controller writes; the

a3s-observer-collector/src/main.rs

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use aya::{
2020
programs::{KProbe, TracePoint, UProbe},
2121
Ebpf,
2222
};
23-
use std::collections::HashMap;
23+
use std::collections::{HashMap, HashSet};
2424
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2525
use std::time::Duration;
2626

@@ -205,9 +205,23 @@ async fn main() -> anyhow::Result<()> {
205205
livenessProbe on it will restart-loop the pod");
206206
}
207207

208+
let collector = CollectorMeta::from_env(
209+
files,
210+
std::env::var_os("A3S_OBSERVER_SSL").is_some(),
211+
attached,
212+
);
213+
208214
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
209215
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
210216
let mut stats = Stats::default();
217+
emit_collector_heartbeat(
218+
exporter.as_ref(),
219+
&collector,
220+
0,
221+
&stats,
222+
0,
223+
exporter.output_drops(),
224+
);
211225
let mut report = tokio::time::interval(Duration::from_secs(60));
212226
report.tick().await; // consume the immediate first tick
213227
loop {
@@ -221,6 +235,7 @@ async fn main() -> anyhow::Result<()> {
221235
.map(|v| v.iter().copied().sum())
222236
.unwrap_or(0);
223237
let output_dropped = exporter.output_drops();
238+
emit_collector_heartbeat(exporter.as_ref(), &collector, 60, &stats, dropped, output_dropped);
224239
tracing::info!(
225240
exec = stats.exec,
226241
exit = stats.exit,
@@ -543,6 +558,110 @@ struct Stats {
543558
llm: u64,
544559
ssl: u64,
545560
sec: u64,
561+
agents: HashSet<String>,
562+
}
563+
564+
struct CollectorMeta {
565+
collector_id: String,
566+
node_name: Option<String>,
567+
namespace: Option<String>,
568+
pod_name: Option<String>,
569+
version: String,
570+
mode: String,
571+
attached_probes: u32,
572+
enabled_features: Vec<String>,
573+
}
574+
575+
impl CollectorMeta {
576+
fn from_env(files: bool, ssl: bool, attached: usize) -> Self {
577+
let node_name = env_any(&["A3S_NODE_NAME", "NODE_NAME", "K8S_NODE_NAME"]).or_else(hostname);
578+
let namespace = env_any(&["A3S_NAMESPACE", "POD_NAMESPACE", "K8S_NAMESPACE"]);
579+
let pod_name = env_any(&["A3S_POD_NAME", "POD_NAME", "HOSTNAME"]);
580+
let collector_id = env_any(&["A3S_OBSERVER_COLLECTOR_ID", "COLLECTOR_ID"])
581+
.or_else(|| pod_name.clone())
582+
.or_else(|| node_name.clone())
583+
.unwrap_or_else(|| "a3s-observer".to_string());
584+
let mut enabled_features = vec![
585+
"exec".to_string(),
586+
"network".to_string(),
587+
"dns".to_string(),
588+
"security".to_string(),
589+
];
590+
if files {
591+
enabled_features.push("files".to_string());
592+
}
593+
if ssl {
594+
enabled_features.push("ssl".to_string());
595+
}
596+
let mode = if files || ssl {
597+
"observe+extensions"
598+
} else {
599+
"observe"
600+
}
601+
.to_string();
602+
Self {
603+
collector_id,
604+
node_name,
605+
namespace,
606+
pod_name,
607+
version: env!("CARGO_PKG_VERSION").to_string(),
608+
mode,
609+
attached_probes: attached as u32,
610+
enabled_features,
611+
}
612+
}
613+
}
614+
615+
fn env_any(names: &[&str]) -> Option<String> {
616+
names.iter().find_map(|name| {
617+
std::env::var(name)
618+
.ok()
619+
.map(|v| v.trim().to_string())
620+
.filter(|v| !v.is_empty())
621+
})
622+
}
623+
624+
fn hostname() -> Option<String> {
625+
std::fs::read_to_string("/etc/hostname")
626+
.ok()
627+
.map(|s| s.trim().to_string())
628+
.filter(|s| !s.is_empty())
629+
}
630+
631+
fn emit_collector_heartbeat(
632+
exporter: &dyn Exporter,
633+
meta: &CollectorMeta,
634+
interval_secs: u64,
635+
stats: &Stats,
636+
dropped: u64,
637+
output_dropped: u64,
638+
) {
639+
exporter.export(&EnrichedEvent {
640+
identity: Identity::default(),
641+
provider: None,
642+
event: AgentEvent::CollectorHeartbeat {
643+
collector_id: meta.collector_id.clone(),
644+
node_name: meta.node_name.clone(),
645+
namespace: meta.namespace.clone(),
646+
pod_name: meta.pod_name.clone(),
647+
version: meta.version.clone(),
648+
mode: meta.mode.clone(),
649+
attached_probes: meta.attached_probes,
650+
enabled_features: meta.enabled_features.clone(),
651+
interval_secs,
652+
observed_agents: stats.agents.len() as u64,
653+
exec: stats.exec,
654+
exit: stats.exit,
655+
egress: stats.egress,
656+
dns: stats.dns,
657+
file: stats.file,
658+
llm: stats.llm,
659+
ssl: stats.ssl,
660+
sec: stats.sec,
661+
dropped,
662+
output_dropped,
663+
},
664+
});
546665
}
547666

548667
/// Export an event and count it by kind for the throughput report.
@@ -558,6 +677,12 @@ fn emit(exporter: &dyn Exporter, stats: &mut Stats, ev: EnrichedEvent) {
558677
AgentEvent::SslContent { .. } => stats.ssl += 1,
559678
AgentEvent::LlmApi { .. } => stats.llm += 1,
560679
AgentEvent::SecurityAction { .. } => stats.sec += 1,
680+
AgentEvent::CollectorHeartbeat { .. } => {}
681+
}
682+
if !matches!(ev.event, AgentEvent::CollectorHeartbeat { .. }) {
683+
if let Some(agent) = &ev.identity.agent {
684+
stats.agents.insert(agent.clone());
685+
}
561686
}
562687
exporter.export(&ev);
563688
}

src/model.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,31 @@ pub enum AgentEvent {
9191
/// ptrace: target pid · bind: port · setuid-root: 0.
9292
detail: u64,
9393
},
94+
/// Collector liveness and throughput telemetry. This is an observer-side control-plane event,
95+
/// not an agent action. It lets downstream platforms detect node/DaemonSet coverage gaps,
96+
/// slow consumers, ring drops, and feature enablement without requiring any agent SDK.
97+
CollectorHeartbeat {
98+
collector_id: String,
99+
node_name: Option<String>,
100+
namespace: Option<String>,
101+
pod_name: Option<String>,
102+
version: String,
103+
mode: String,
104+
attached_probes: u32,
105+
enabled_features: Vec<String>,
106+
interval_secs: u64,
107+
observed_agents: u64,
108+
exec: u64,
109+
exit: u64,
110+
egress: u64,
111+
dns: u64,
112+
file: u64,
113+
llm: u64,
114+
ssl: u64,
115+
sec: u64,
116+
dropped: u64,
117+
output_dropped: u64,
118+
},
94119
}
95120

96121
/// An [`AgentEvent`] tagged with the resolved [`Identity`] and, for LLM calls, the

0 commit comments

Comments
 (0)