Skip to content

Commit fab5164

Browse files
committed
feat(telemetry): add anonymous opt-out usage telemetry
Signed-off-by: Kirit93 <kthadaka@nvidia.com>
1 parent 555680c commit fab5164

21 files changed

Lines changed: 1744 additions & 58 deletions

crates/openshell-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub mod progress;
2424
pub mod proto;
2525
pub mod sandbox_env;
2626
pub mod settings;
27+
pub mod telemetry;
2728
pub mod time;
2829

2930
pub use config::{ComputeDriverKind, Config, OidcConfig, TlsConfig};
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Best-effort anonymous telemetry emission helpers.
5+
6+
use serde_json::{Value, json};
7+
use std::io::Write;
8+
use std::path::PathBuf;
9+
use std::process::{Command, Stdio};
10+
11+
const SOURCE: &str = "openshell";
12+
13+
fn telemetry_enabled() -> bool {
14+
telemetry_enabled_from(std::env::var("OPENSHELL_TELEMETRY_ENABLED").ok().as_deref())
15+
}
16+
17+
fn telemetry_enabled_from(value: Option<&str>) -> bool {
18+
let value = value.unwrap_or("true");
19+
!matches!(
20+
value.trim().to_ascii_lowercase().as_str(),
21+
"0" | "false" | "no" | "off"
22+
)
23+
}
24+
25+
fn publisher_path() -> PathBuf {
26+
std::env::var_os("OPENSHELL_TELEMETRY_PUBLISHER").map_or_else(
27+
|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../scripts/publish_telemetry.py"),
28+
PathBuf::from,
29+
)
30+
}
31+
32+
fn emit_event(event: Value) {
33+
if !telemetry_enabled() {
34+
return;
35+
}
36+
37+
let Ok(payload) = serde_json::to_vec(&event) else {
38+
return;
39+
};
40+
let script = publisher_path();
41+
42+
let Ok(mut child) = Command::new("python3")
43+
.arg(script)
44+
.stdin(Stdio::piped())
45+
.stdout(Stdio::null())
46+
.stderr(Stdio::null())
47+
.spawn()
48+
else {
49+
return;
50+
};
51+
52+
if let Some(mut stdin) = child.stdin.take() {
53+
let _ = stdin.write_all(&payload);
54+
}
55+
56+
std::thread::spawn(move || {
57+
let _ = child.wait();
58+
});
59+
}
60+
61+
pub fn emit_lifecycle(resource: &str, operation: &str, outcome: &str) {
62+
emit_event(json!({
63+
"nvidiaSource": SOURCE,
64+
"resource": resource,
65+
"operation": operation,
66+
"outcome": outcome,
67+
}));
68+
}
69+
70+
pub fn emit_provider_lifecycle(operation: &str, outcome: &str, provider_profile: &str) {
71+
emit_event(json!({
72+
"nvidiaSource": SOURCE,
73+
"operation": operation,
74+
"outcome": outcome,
75+
"providerProfile": provider_profile,
76+
}));
77+
}
78+
79+
pub fn emit_sandbox_create(
80+
outcome: &str,
81+
requested_gpu: bool,
82+
provider_count: u64,
83+
has_custom_policy: bool,
84+
template_source: &str,
85+
) {
86+
emit_event(json!({
87+
"nvidiaSource": SOURCE,
88+
"outcome": outcome,
89+
"requestedGpu": requested_gpu,
90+
"providerCount": provider_count,
91+
"hasCustomPolicy": has_custom_policy,
92+
"templateSource": template_source,
93+
}));
94+
}
95+
96+
pub fn emit_policy_decision(operation: &str, outcome: &str, rule_count: u64) {
97+
emit_event(json!({
98+
"nvidiaSource": SOURCE,
99+
"operation": operation,
100+
"outcome": outcome,
101+
"ruleCount": rule_count,
102+
}));
103+
}
104+
105+
pub fn emit_sandbox_activity_summary<I, S>(
106+
network_activity_count: u64,
107+
denied_action_count: u64,
108+
denial_rate_pct: f64,
109+
denials_by_group: I,
110+
) where
111+
I: IntoIterator<Item = (S, u64)>,
112+
S: Into<String>,
113+
{
114+
let mut rows: Vec<Value> = denials_by_group
115+
.into_iter()
116+
.map(|(group, count)| {
117+
json!({
118+
"denyGroup": group.into(),
119+
"deniedCount": count,
120+
})
121+
})
122+
.collect();
123+
rows.sort_by(|left, right| {
124+
left["denyGroup"]
125+
.as_str()
126+
.unwrap_or_default()
127+
.cmp(right["denyGroup"].as_str().unwrap_or_default())
128+
});
129+
emit_event(json!({
130+
"nvidiaSource": SOURCE,
131+
"networkActivityCount": network_activity_count,
132+
"deniedActionCount": denied_action_count,
133+
"denialRatePct": denial_rate_pct,
134+
"denialsByGroup": rows,
135+
}));
136+
}
137+
138+
#[cfg(test)]
139+
mod tests {
140+
use super::*;
141+
142+
#[test]
143+
fn telemetry_enabled_defaults_true() {
144+
assert!(telemetry_enabled_from(None));
145+
}
146+
147+
#[test]
148+
fn telemetry_enabled_honors_false_values() {
149+
assert!(!telemetry_enabled_from(Some("off")));
150+
assert!(!telemetry_enabled_from(Some("false")));
151+
assert!(telemetry_enabled_from(Some("yes")));
152+
}
153+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Anonymous sandbox network activity counter aggregation.
5+
6+
use std::collections::HashMap;
7+
use std::future::Future;
8+
use tokio::sync::mpsc;
9+
use tracing::debug;
10+
11+
#[derive(Debug, Clone)]
12+
pub struct ActivityEvent {
13+
pub denied: bool,
14+
pub deny_group: &'static str,
15+
}
16+
17+
#[derive(Debug, Clone, PartialEq, Eq)]
18+
pub struct FlushableActivitySummary {
19+
pub network_activity_count: u32,
20+
pub denied_action_count: u32,
21+
pub denials_by_group: Vec<(String, u32)>,
22+
}
23+
24+
pub struct ActivityAggregator {
25+
rx: mpsc::UnboundedReceiver<ActivityEvent>,
26+
network_activity_count: u32,
27+
denied_action_count: u32,
28+
denials_by_group: HashMap<String, u32>,
29+
flush_interval_secs: u64,
30+
}
31+
32+
impl ActivityAggregator {
33+
pub fn new(rx: mpsc::UnboundedReceiver<ActivityEvent>, flush_interval_secs: u64) -> Self {
34+
Self {
35+
rx,
36+
network_activity_count: 0,
37+
denied_action_count: 0,
38+
denials_by_group: HashMap::new(),
39+
flush_interval_secs,
40+
}
41+
}
42+
43+
pub async fn run<F, Fut>(mut self, flush_callback: F)
44+
where
45+
F: Fn(FlushableActivitySummary) -> Fut,
46+
Fut: Future<Output = ()>,
47+
{
48+
let mut flush_interval =
49+
tokio::time::interval(std::time::Duration::from_secs(self.flush_interval_secs));
50+
flush_interval.tick().await;
51+
52+
loop {
53+
tokio::select! {
54+
event = self.rx.recv() => {
55+
if let Some(event) = event {
56+
self.ingest(event);
57+
} else {
58+
if let Some(summary) = self.drain() {
59+
flush_callback(summary).await;
60+
}
61+
debug!("ActivityAggregator: channel closed, exiting");
62+
return;
63+
}
64+
}
65+
_ = flush_interval.tick() => {
66+
if let Some(summary) = self.drain() {
67+
debug!(
68+
count = summary.network_activity_count,
69+
denied = summary.denied_action_count,
70+
"ActivityAggregator: flushing anonymous activity summary"
71+
);
72+
flush_callback(summary).await;
73+
}
74+
}
75+
}
76+
}
77+
}
78+
79+
fn ingest(&mut self, event: ActivityEvent) {
80+
self.network_activity_count = self.network_activity_count.saturating_add(1);
81+
if event.denied {
82+
self.denied_action_count = self.denied_action_count.saturating_add(1);
83+
let group = sanitize_deny_group(event.deny_group).to_string();
84+
let count = self.denials_by_group.entry(group).or_default();
85+
*count = count.saturating_add(1);
86+
}
87+
}
88+
89+
fn drain(&mut self) -> Option<FlushableActivitySummary> {
90+
if self.network_activity_count == 0 {
91+
return None;
92+
}
93+
let mut denials_by_group: Vec<(String, u32)> = self.denials_by_group.drain().collect();
94+
denials_by_group.sort_by(|left, right| left.0.cmp(&right.0));
95+
let summary = FlushableActivitySummary {
96+
network_activity_count: self.network_activity_count,
97+
denied_action_count: self.denied_action_count,
98+
denials_by_group,
99+
};
100+
self.network_activity_count = 0;
101+
self.denied_action_count = 0;
102+
Some(summary)
103+
}
104+
}
105+
106+
pub fn sanitize_deny_group(raw: &str) -> &'static str {
107+
match raw {
108+
"connect_policy" | "connect" | "l4_deny" => "connect_policy",
109+
"forward_policy" | "forward" => "forward_policy",
110+
"l7_policy" | "l7" | "l7_deny" | "forward-l7-deny" => "l7_policy",
111+
"l7_parse_rejection" | "parse_rejection" => "l7_parse_rejection",
112+
"ssrf" => "ssrf",
113+
"bypass" => "bypass",
114+
"policy_stale" => "policy_stale",
115+
_ => "unknown",
116+
}
117+
}
118+
119+
#[cfg(test)]
120+
fn denial_rate_pct(network_activity_count: u32, denied_action_count: u32) -> f64 {
121+
if network_activity_count == 0 {
122+
return 0.0;
123+
}
124+
((f64::from(denied_action_count) / f64::from(network_activity_count)) * 100.0).clamp(0.0, 100.0)
125+
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use super::*;
130+
131+
fn assert_float_eq(actual: f64, expected: f64) {
132+
assert!((actual - expected).abs() <= f64::EPSILON);
133+
}
134+
135+
#[test]
136+
fn deny_group_sanitization_uses_allowlist() {
137+
assert_eq!(sanitize_deny_group("connect"), "connect_policy");
138+
assert_eq!(sanitize_deny_group("forward-l7-deny"), "l7_policy");
139+
assert_eq!(sanitize_deny_group("host=example.test/path"), "unknown");
140+
assert_eq!(sanitize_deny_group("acme.internal:443"), "unknown");
141+
assert_eq!(
142+
sanitize_deny_group("binary=/usr/local/bin/private"),
143+
"unknown"
144+
);
145+
}
146+
147+
#[test]
148+
fn denial_rate_handles_zero_and_clamps() {
149+
assert_float_eq(denial_rate_pct(0, 10), 0.0);
150+
assert_float_eq(denial_rate_pct(4, 1), 25.0);
151+
assert_float_eq(denial_rate_pct(4, 10), 100.0);
152+
}
153+
}

crates/openshell-sandbox/src/bypass_monitor.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
//! the monitor logs a one-time warning and returns. The iptables REJECT rules
1717
//! still provide fast-fail UX — the monitor only adds diagnostic visibility.
1818
19+
use crate::activity_aggregator::ActivityEvent;
1920
use crate::denial_aggregator::DenialEvent;
2021
use openshell_ocsf::{
2122
ActionId, ActivityId, ConfidenceId, DetectionFindingBuilder, DispositionId, Endpoint,
@@ -118,6 +119,7 @@ pub fn spawn(
118119
namespace_name: String,
119120
entrypoint_pid: Arc<AtomicU32>,
120121
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
122+
activity_tx: Option<mpsc::UnboundedSender<ActivityEvent>>,
121123
) -> Option<tokio::task::JoinHandle<()>> {
122124
use std::io::BufRead;
123125
use std::process::{Command, Stdio};
@@ -277,6 +279,12 @@ pub fn spawn(
277279
l7_path: None,
278280
});
279281
}
282+
if let Some(ref tx) = activity_tx {
283+
let _ = tx.send(ActivityEvent {
284+
denied: true,
285+
deny_group: "bypass",
286+
});
287+
}
280288
}
281289

282290
// Clean up the dmesg child process.

crates/openshell-sandbox/src/grpc_client.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ use std::time::Duration;
1010
use miette::{IntoDiagnostic, Result, WrapErr};
1111
use openshell_core::proto::{
1212
DenialSummary, GetDraftPolicyRequest, GetInferenceBundleRequest, GetInferenceBundleResponse,
13-
GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, PolicyChunk, PolicySource,
14-
PolicyStatus, ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy,
15-
SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UpdateConfigRequest,
16-
inference_client::InferenceClient, open_shell_client::OpenShellClient,
13+
GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, NetworkActivitySummary,
14+
PolicyChunk, PolicySource, PolicyStatus, ReportPolicyStatusRequest,
15+
SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse,
16+
UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient,
1717
};
1818
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
1919
use tracing::debug;
@@ -307,6 +307,7 @@ impl CachedOpenShellClient {
307307
sandbox_name: &str,
308308
summaries: Vec<DenialSummary>,
309309
proposed_chunks: Vec<PolicyChunk>,
310+
network_activity_summaries: Vec<NetworkActivitySummary>,
310311
analysis_mode: &str,
311312
) -> Result<SubmitPolicyAnalysisResponse> {
312313
let response = self
@@ -316,6 +317,7 @@ impl CachedOpenShellClient {
316317
name: sandbox_name.to_string(),
317318
summaries,
318319
proposed_chunks,
320+
network_activity_summaries,
319321
analysis_mode: analysis_mode.to_string(),
320322
})
321323
.await

crates/openshell-sandbox/src/l7/graphql.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,7 @@ network_policies:
801801
ancestors: Vec::new(),
802802
cmdline_paths: Vec::new(),
803803
secret_resolver: None,
804+
activity_tx: None,
804805
};
805806
let request_info = crate::l7::L7RequestInfo {
806807
action: req.action,

0 commit comments

Comments
 (0)