Skip to content

Commit c04ebd1

Browse files
ZhiXiao-LinRoyLin
andauthored
feat: add workload observation contract (#9)
Co-authored-by: RoyLin <roylin@RoyLindeMacBook-Pro.local>
1 parent b9baf86 commit c04ebd1

8 files changed

Lines changed: 629 additions & 0 deletions

File tree

CHANGELOG.md

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

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

5+
## [Unreleased]
6+
7+
### Added
8+
9+
- A provider-neutral workload attribution contract: `WorkloadIdentity` requires stable workload,
10+
deployment, immutable revision, logical replica, provider-unit, and node IDs. Each
11+
`WorkloadIdentityValue` is bounded to 128 ASCII bytes and rejects whitespace, control
12+
characters, free-form Unicode, and non-canonical boundary characters without echoing rejected
13+
input in validation errors.
14+
- Explicit `ObservationMetadata` for sampled signals, including observation timestamp, optional
15+
sample timestamp and collection interval, and `fresh`, `stale`, `unavailable`, or `unknown`
16+
state. Unavailable and unknown observations represent missing data without a zero-valued
17+
sentinel.
18+
- Optional `workload` and `observation` fields in NDJSON `EnrichedEvent` output.
19+
`IdentityResolver::resolve_workload` defaults to `None`, preserving existing resolver
20+
implementations and preventing incomplete attribution.
21+
22+
This contract is the first slice of workload metrics support. Multi-replica Linux collection,
23+
resource/restart/availability measurements, lifecycle fixtures, and OTLP/Prometheus metric parity
24+
are not included yet.
25+
526
## [0.11.0] — SecurityAction: privesc / injection / open-port
627

728
### Added

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,49 @@ Filter with `jq`, e.g. every LLM call and its provider:
8383
Set `A3S_OBSERVER_COLLECTOR_ID` and `A3S_NODE_NAME` in DaemonSets when you want stable collector
8484
identity in downstream fleet-health views. If unset, the collector falls back to pod/host names.
8585

86+
### Workload attribution and freshness contract
87+
88+
`EnrichedEvent` can also carry provider-neutral `workload` and `observation` objects for
89+
per-replica signals:
90+
91+
```json
92+
{
93+
"workload": {
94+
"workload_id": "workload-01HV7F5N",
95+
"deployment_id": "deployment-01HV7F6A",
96+
"revision_id": "revision-sha256:8f3a",
97+
"replica_id": "replica-0007",
98+
"provider_unit_id": "containerd:4f6c2d8a",
99+
"node_id": "node-us-east-1a-03"
100+
},
101+
"observation": {
102+
"observed_at_unix_nanos": 1720000015000000000,
103+
"sampled_at_unix_nanos": 1720000014000000000,
104+
"collection_interval_nanos": 15000000000,
105+
"freshness": "fresh"
106+
}
107+
}
108+
```
109+
110+
`WorkloadIdentity` is complete by construction: a producer must provide stable workload,
111+
deployment, immutable revision, logical replica, current provider-unit, and node IDs. Each ID is
112+
an opaque platform identifier limited to 128 ASCII bytes and a label-safe alphabet. Producers
113+
must normalize provider IDs and must never copy tenant secrets, display names, or raw user labels
114+
into these fields. A logical `replica_id` survives process restart, adoption, and rescheduling;
115+
`provider_unit_id` changes when the runtime unit is replaced.
116+
117+
`ObservationMetadata` reports an observation time, an optional collection interval, and one of
118+
`fresh`, `stale`, `unavailable`, or `unknown`. Fresh and stale data include the actual sample
119+
timestamp. Unavailable and unknown observations omit it, giving producers an explicit
120+
missing-data state instead of a zero-usage sentinel.
121+
122+
This is the transport contract, not a claim that per-replica resource collection is complete.
123+
Existing `IdentityResolver` implementations return no workload identity by default, and the
124+
node-wide collector intentionally does not apply one static environment identity to every event.
125+
Multi-replica Linux collection, CPU/memory/network/process/restart/availability samples,
126+
restart/adoption fixtures, and equivalent OTLP and Prometheus metric exporters remain follow-up
127+
work.
128+
86129
## Intervene — egress / file / exec (opt-in)
87130

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

a3s-observer-collector/src/main.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,8 @@ async fn main() -> anyhow::Result<()> {
260260
if let Some(ev) = read_pod::<ExecEvent>(&item) {
261261
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
262262
identity: identity_for(&resolver, ev.pid, &ev.comm),
263+
workload: resolver.resolve_workload(ev.pid, 0, 0),
264+
observation: None,
263265
process: Some(process_context(ev.pid, &ev.comm)),
264266
provider: None,
265267
event: AgentEvent::ToolExec {
@@ -276,6 +278,8 @@ async fn main() -> anyhow::Result<()> {
276278
if let Some(ev) = read_pod::<ExitEvent>(&item) {
277279
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
278280
identity: identity_for(&resolver, ev.pid, &ev.comm),
281+
workload: resolver.resolve_workload(ev.pid, 0, 0),
282+
observation: None,
279283
process: Some(process_context(ev.pid, &ev.comm)),
280284
provider: None,
281285
event: AgentEvent::ProcessExit {
@@ -296,6 +300,8 @@ async fn main() -> anyhow::Result<()> {
296300
};
297301
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
298302
identity: identity_for(&resolver, ev.pid, &ev.comm),
303+
workload: resolver.resolve_workload(ev.pid, 0, 0),
304+
observation: None,
299305
process: Some(process_context(ev.pid, &ev.comm)),
300306
provider: None,
301307
event: AgentEvent::SecurityAction {
@@ -316,6 +322,8 @@ async fn main() -> anyhow::Result<()> {
316322
peers.insert(sock_key(ev.pid, ev.fd), (peer, ev.port));
317323
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
318324
identity: identity_for(&resolver, ev.pid, &ev.comm),
325+
workload: resolver.resolve_workload(ev.pid, 0, 0),
326+
observation: None,
319327
process: Some(process_context(ev.pid, &ev.comm)),
320328
provider: None,
321329
event: AgentEvent::Egress {
@@ -347,6 +355,8 @@ async fn main() -> anyhow::Result<()> {
347355
llm_meta.insert(sock_key(ev.pid, ev.fd), (sni.clone(), provider.clone(), peer));
348356
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
349357
identity: identity_for(&resolver, ev.pid, &ev.comm),
358+
workload: resolver.resolve_workload(ev.pid, 0, 0),
359+
observation: None,
350360
process: Some(process_context(ev.pid, &ev.comm)),
351361
provider,
352362
event: AgentEvent::Egress {
@@ -365,6 +375,8 @@ async fn main() -> anyhow::Result<()> {
365375
if let Some(query) = parse_dns_qname(&ev.data[..len]) {
366376
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
367377
identity: identity_for(&resolver, ev.pid, &ev.comm),
378+
workload: resolver.resolve_workload(ev.pid, 0, 0),
379+
observation: None,
368380
process: Some(process_context(ev.pid, &ev.comm)),
369381
provider: None,
370382
event: AgentEvent::Dns { pid: ev.pid, query },
@@ -388,6 +400,8 @@ async fn main() -> anyhow::Result<()> {
388400
};
389401
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
390402
identity: identity_for(&resolver, ev.pid, &ev.comm),
403+
workload: resolver.resolve_workload(ev.pid, 0, 0),
404+
observation: None,
391405
process: Some(process_context(ev.pid, &ev.comm)),
392406
provider: None,
393407
event,
@@ -405,6 +419,8 @@ async fn main() -> anyhow::Result<()> {
405419
if provider.is_some() {
406420
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
407421
identity: identity_for(&resolver, ev.pid, &ev.comm),
422+
workload: resolver.resolve_workload(ev.pid, 0, 0),
423+
observation: None,
408424
process: Some(process_context(ev.pid, &ev.comm)),
409425
provider,
410426
event: AgentEvent::LlmCall {
@@ -428,12 +444,15 @@ async fn main() -> anyhow::Result<()> {
428444
let content = String::from_utf8_lossy(&ev.data[..len]).into_owned();
429445
if !content.is_empty() {
430446
let identity = identity_for(&resolver, ev.pid, &ev.comm);
447+
let workload = resolver.resolve_workload(ev.pid, 0, 0);
431448
// Structured LLM telemetry (model/tokens) alongside the raw content.
432449
if let Some((model, prompt_tokens, completion_tokens)) =
433450
parse_llm_meta(&content)
434451
{
435452
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
436453
identity: identity.clone(),
454+
workload: workload.clone(),
455+
observation: None,
437456
process: Some(process_context(ev.pid, &ev.comm)),
438457
provider: None,
439458
event: AgentEvent::LlmApi {
@@ -447,6 +466,8 @@ async fn main() -> anyhow::Result<()> {
447466
}
448467
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
449468
identity,
469+
workload,
470+
observation: None,
450471
process: Some(process_context(ev.pid, &ev.comm)),
451472
provider: None,
452473
event: AgentEvent::SslContent {
@@ -674,6 +695,8 @@ fn emit_collector_heartbeat(
674695
) {
675696
exporter.export(&EnrichedEvent {
676697
identity: Identity::default(),
698+
workload: None,
699+
observation: None,
677700
process: None,
678701
provider: None,
679702
event: AgentEvent::CollectorHeartbeat {

src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@
1616
pub mod model;
1717
pub mod policy;
1818
pub mod traits;
19+
pub mod workload;
1920

2021
pub use model::{AgentEvent, EnrichedEvent, ProcessContext};
2122
pub use policy::{parse_egress_policy, AllowAll, Policy, ProviderPolicy, Verdict};
2223
pub use traits::{
2324
read_ppid, Exporter, Identity, IdentityResolver, JsonExporter, KubeResolver, LogExporter,
2425
ProcResolver, Provider, ServiceClassifier, SniClassifier,
2526
};
27+
pub use workload::{
28+
Freshness, ObservationMetadata, ObservationMetadataError, WorkloadIdentity,
29+
WorkloadIdentityValue, WorkloadIdentityValueError, MAX_WORKLOAD_IDENTITY_VALUE_LEN,
30+
};

src/model.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! events the [`Exporter`](crate::Exporter) receives.
33
44
use crate::traits::{Identity, Provider};
5+
use crate::workload::{ObservationMetadata, WorkloadIdentity};
56
use serde::Serialize;
67
use std::net::IpAddr;
78
use std::time::Duration;
@@ -137,6 +138,12 @@ pub enum AgentEvent {
137138
#[derive(Debug, Clone, Serialize)]
138139
pub struct EnrichedEvent {
139140
pub identity: Identity,
141+
/// Complete workload attribution, when a resolver can prove every stable identity field.
142+
#[serde(skip_serializing_if = "Option::is_none")]
143+
pub workload: Option<WorkloadIdentity>,
144+
/// Explicit timing and freshness for sampled signals. Consumers must not infer zero when absent.
145+
#[serde(skip_serializing_if = "Option::is_none")]
146+
pub observation: Option<ObservationMetadata>,
140147
#[serde(skip_serializing_if = "Option::is_none")]
141148
pub process: Option<ProcessContext>,
142149
pub provider: Option<Provider>,

src/traits.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! probes.
66
77
use crate::model::EnrichedEvent;
8+
use crate::workload::WorkloadIdentity;
89
use serde::Serialize;
910
use std::net::IpAddr;
1011

@@ -21,6 +22,19 @@ pub struct Identity {
2122
/// Implementations: k8s (cgroup→pod), docker, a3s-box (pid/netns→box), bare pid-tree.
2223
pub trait IdentityResolver: Send + Sync {
2324
fn resolve(&self, pid: u32, cgroup_id: u64, netns: u64) -> Identity;
25+
26+
/// Resolve a complete, provider-neutral workload identity when one is available.
27+
///
28+
/// Existing process-only resolvers remain valid and default to no workload attribution.
29+
/// Implementations must return `None` rather than inventing or partially filling identity.
30+
fn resolve_workload(
31+
&self,
32+
_pid: u32,
33+
_cgroup_id: u64,
34+
_netns: u64,
35+
) -> Option<WorkloadIdentity> {
36+
None
37+
}
2438
}
2539

2640
/// Known service providers, identified language-agnostically from TLS SNI / DNS.
@@ -346,6 +360,8 @@ mod tests {
346360
task: Some("1".into()),
347361
session: None,
348362
},
363+
workload: None,
364+
observation: None,
349365
process: None,
350366
provider: None,
351367
event: AgentEvent::ProcessExit {

0 commit comments

Comments
 (0)