Skip to content

Commit 47600a6

Browse files
committed
Fixed agent feedback
1 parent fef8402 commit 47600a6

7 files changed

Lines changed: 456 additions & 211 deletions

File tree

crates/openshell-core/src/telemetry.rs

Lines changed: 190 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
use chrono::{SecondsFormat, Utc};
77
use reqwest::blocking::Client;
88
use serde_json::{Value, json};
9+
use std::collections::BTreeMap;
910
use std::sync::{OnceLock, mpsc};
1011
use std::thread;
1112
use std::time::Duration;
1213

1314
const TELEMETRY_EVENT_QUEUE_CAPACITY: usize = 1024;
15+
const MAX_TELEMETRY_INTEGER: u64 = 9_223_372_036_854_775_807;
1416
const CLIENT_ID: &str = "415437562476676";
1517
const DEFAULT_ENDPOINT: &str = "https://events.telemetry.data-uat.nvidia.com/v1.1/events/json";
1618
const EVENT_SCHEMA_VERSION: &str = "2.0";
@@ -24,10 +26,11 @@ static TELEMETRY_SENDER: OnceLock<Option<mpsc::SyncSender<TelemetryEvent>>> = On
2426
struct TelemetryEvent {
2527
endpoint: String,
2628
name: &'static str,
29+
event_ts: String,
2730
event: Value,
2831
}
2932

30-
fn telemetry_enabled() -> bool {
33+
pub fn enabled() -> bool {
3134
telemetry_enabled_from(std::env::var("OPENSHELL_TELEMETRY_ENABLED").ok().as_deref())
3235
}
3336

@@ -61,7 +64,7 @@ fn timestamp() -> String {
6164
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
6265
}
6366

64-
fn build_payload(name: &str, event: Value, ts: &str) -> Value {
67+
fn build_payload(name: &str, event: Value, event_ts: &str, sent_ts: &str) -> Value {
6568
json!({
6669
"browserType": "undefined",
6770
"clientId": CLIENT_ID,
@@ -90,12 +93,12 @@ fn build_payload(name: &str, event: Value, ts: &str) -> Value {
9093
"integrationId": "undefined",
9194
"productName": "undefined",
9295
"productVersion": "undefined",
93-
"sentTs": ts,
96+
"sentTs": sent_ts,
9497
"sessionId": "undefined",
9598
"userId": "undefined",
9699
"events": [
97100
{
98-
"ts": ts,
101+
"ts": event_ts,
99102
"parameters": event,
100103
"name": name,
101104
}
@@ -118,7 +121,7 @@ fn telemetry_sender() -> Option<&'static mpsc::SyncSender<TelemetryEvent>> {
118121

119122
fn telemetry_worker(rx: mpsc::Receiver<TelemetryEvent>) {
120123
for event in rx {
121-
let payload = build_payload(event.name, event.event, &timestamp());
124+
let payload = build_payload(event.name, event.event, &event.event_ts, &timestamp());
122125
let _ = publish_payload(&event.endpoint, payload);
123126
}
124127
}
@@ -141,7 +144,7 @@ fn try_enqueue_event(sender: &mpsc::SyncSender<TelemetryEvent>, event: Telemetry
141144
}
142145

143146
fn emit_event(name: &'static str, event: Value) {
144-
if !telemetry_enabled() {
147+
if !enabled() {
145148
return;
146149
}
147150
let Some(endpoint) = telemetry_endpoint() else {
@@ -156,12 +159,22 @@ fn emit_event(name: &'static str, event: Value) {
156159
TelemetryEvent {
157160
endpoint,
158161
name,
162+
event_ts: timestamp(),
159163
event,
160164
},
161165
);
162166
}
163167

164168
pub fn emit_lifecycle(resource: &str, operation: &str, outcome: &str) {
169+
let Some(resource) = lifecycle_resource(resource) else {
170+
return;
171+
};
172+
let Some(operation) = lifecycle_operation(operation) else {
173+
return;
174+
};
175+
let Some(outcome) = telemetry_outcome(outcome) else {
176+
return;
177+
};
165178
emit_event(
166179
"openshell_lifecycle_event",
167180
json!({
@@ -174,6 +187,13 @@ pub fn emit_lifecycle(resource: &str, operation: &str, outcome: &str) {
174187
}
175188

176189
pub fn emit_provider_lifecycle(operation: &str, outcome: &str, provider_profile: &str) {
190+
let Some(operation) = lifecycle_operation(operation) else {
191+
return;
192+
};
193+
let Some(outcome) = telemetry_outcome(outcome) else {
194+
return;
195+
};
196+
let provider_profile = provider_profile_bucket(provider_profile);
177197
emit_event(
178198
"openshell_provider_lifecycle_event",
179199
json!({
@@ -192,6 +212,13 @@ pub fn emit_sandbox_create(
192212
has_custom_policy: bool,
193213
template_source: &str,
194214
) {
215+
let Some(outcome) = telemetry_outcome(outcome) else {
216+
return;
217+
};
218+
if !valid_count(provider_count) {
219+
return;
220+
}
221+
let template_source = sandbox_template_source_bucket(template_source);
195222
emit_event(
196223
"openshell_sandbox_create_event",
197224
json!({
@@ -206,6 +233,15 @@ pub fn emit_sandbox_create(
206233
}
207234

208235
pub fn emit_policy_decision(operation: &str, outcome: &str, rule_count: u64) {
236+
let Some(operation) = policy_decision_operation(operation) else {
237+
return;
238+
};
239+
let Some(outcome) = telemetry_outcome(outcome) else {
240+
return;
241+
};
242+
if !valid_count(rule_count) {
243+
return;
244+
}
209245
emit_event(
210246
"openshell_policy_decision_event",
211247
json!({
@@ -226,21 +262,20 @@ pub fn emit_sandbox_activity_summary<I, S>(
226262
I: IntoIterator<Item = (S, u64)>,
227263
S: Into<String>,
228264
{
229-
let mut rows: Vec<Value> = denials_by_group
265+
if !valid_count(network_activity_count)
266+
|| !valid_count(denied_action_count)
267+
|| !denial_rate_pct.is_finite()
268+
|| !(0.0..=100.0).contains(&denial_rate_pct)
269+
{
270+
return;
271+
}
272+
let Some(denials_by_group) = sanitize_denials_by_group(denials_by_group) else {
273+
return;
274+
};
275+
let rows: Vec<Value> = denials_by_group
230276
.into_iter()
231-
.map(|(group, count)| {
232-
json!({
233-
"denyGroup": group.into(),
234-
"deniedCount": count,
235-
})
236-
})
277+
.map(|(group, count)| json!({ "denyGroup": group, "deniedCount": count }))
237278
.collect();
238-
rows.sort_by(|left, right| {
239-
left["denyGroup"]
240-
.as_str()
241-
.unwrap_or_default()
242-
.cmp(right["denyGroup"].as_str().unwrap_or_default())
243-
});
244279
emit_event(
245280
"openshell_sandbox_activity_summary_event",
246281
json!({
@@ -253,6 +288,107 @@ pub fn emit_sandbox_activity_summary<I, S>(
253288
);
254289
}
255290

291+
fn valid_count(value: u64) -> bool {
292+
value <= MAX_TELEMETRY_INTEGER
293+
}
294+
295+
fn telemetry_outcome(raw: &str) -> Option<&'static str> {
296+
match raw {
297+
"success" => Some("success"),
298+
"failure" => Some("failure"),
299+
_ => None,
300+
}
301+
}
302+
303+
fn lifecycle_resource(raw: &str) -> Option<&'static str> {
304+
match raw {
305+
"sandbox" => Some("sandbox"),
306+
"sandbox_policy" => Some("sandbox_policy"),
307+
_ => None,
308+
}
309+
}
310+
311+
fn lifecycle_operation(raw: &str) -> Option<&'static str> {
312+
match raw {
313+
"create" => Some("create"),
314+
"delete" => Some("delete"),
315+
"update" => Some("update"),
316+
_ => None,
317+
}
318+
}
319+
320+
fn policy_decision_operation(raw: &str) -> Option<&'static str> {
321+
match raw {
322+
"approve" => Some("approve"),
323+
"reject" => Some("reject"),
324+
"undo" => Some("undo"),
325+
"approve_all" => Some("approve_all"),
326+
_ => None,
327+
}
328+
}
329+
330+
fn sandbox_template_source_bucket(raw: &str) -> &'static str {
331+
match raw {
332+
"default" => "default",
333+
"image" => "image",
334+
_ => "undefined",
335+
}
336+
}
337+
338+
fn provider_profile_bucket(raw: &str) -> &'static str {
339+
match raw.trim().to_ascii_lowercase().as_str() {
340+
"anthropic" => "anthropic",
341+
"claude" => "claude",
342+
"codex" => "codex",
343+
"copilot" => "copilot",
344+
"github" => "github",
345+
"gitlab" => "gitlab",
346+
"nvidia" => "nvidia",
347+
"openai" => "openai",
348+
"opencode" => "opencode",
349+
"outlook" => "outlook",
350+
_ => "custom",
351+
}
352+
}
353+
354+
fn deny_group_bucket(raw: &str) -> &'static str {
355+
match raw {
356+
"connect_policy" | "connect" | "l4_deny" => "connect_policy",
357+
"forward_policy" | "forward" => "forward_policy",
358+
"l7_policy" | "l7" | "l7_deny" | "forward-l7-deny" => "l7_policy",
359+
"l7_parse_rejection" | "parse_rejection" => "l7_parse_rejection",
360+
"ssrf" => "ssrf",
361+
"bypass" => "bypass",
362+
"policy_stale" => "policy_stale",
363+
_ => "unknown",
364+
}
365+
}
366+
367+
fn sanitize_denials_by_group<I, S>(denials_by_group: I) -> Option<BTreeMap<&'static str, u64>>
368+
where
369+
I: IntoIterator<Item = (S, u64)>,
370+
S: Into<String>,
371+
{
372+
let mut sanitized = BTreeMap::<&'static str, u64>::new();
373+
for (group, count) in denials_by_group {
374+
if !valid_count(count) {
375+
return None;
376+
}
377+
let group = group.into();
378+
let bucket = deny_group_bucket(&group);
379+
let next_count = sanitized
380+
.get(bucket)
381+
.copied()
382+
.unwrap_or(0)
383+
.checked_add(count)?;
384+
if !valid_count(next_count) {
385+
return None;
386+
}
387+
sanitized.insert(bucket, next_count);
388+
}
389+
Some(sanitized)
390+
}
391+
256392
#[cfg(test)]
257393
mod tests {
258394
use super::*;
@@ -291,6 +427,7 @@ mod tests {
291427
"templateSource": "default",
292428
}),
293429
"2026-05-18T00:00:00.000Z",
430+
"2026-05-18T00:00:01.000Z",
294431
);
295432

296433
assert_eq!(payload["clientId"], CLIENT_ID);
@@ -303,6 +440,7 @@ mod tests {
303440
);
304441
assert_eq!(payload["events"][0]["parameters"]["nvidiaSource"], SOURCE);
305442
assert_eq!(payload["events"][0]["ts"], "2026-05-18T00:00:00.000Z");
443+
assert_eq!(payload["sentTs"], "2026-05-18T00:00:01.000Z");
306444
}
307445

308446
#[test]
@@ -311,6 +449,7 @@ mod tests {
311449
let event = || TelemetryEvent {
312450
endpoint: "https://example.test/events".to_string(),
313451
name: "openshell_lifecycle_event",
452+
event_ts: "2026-05-18T00:00:00.000Z".to_string(),
314453
event: json!({
315454
"nvidiaSource": SOURCE,
316455
"resource": "sandbox",
@@ -322,4 +461,36 @@ mod tests {
322461
assert!(try_enqueue_event(&tx, event()));
323462
assert!(!try_enqueue_event(&tx, event()));
324463
}
464+
465+
#[test]
466+
fn telemetry_validation_maps_privacy_sensitive_strings_to_safe_buckets() {
467+
assert_eq!(provider_profile_bucket("corp-llm-prod"), "custom");
468+
assert_eq!(
469+
sandbox_template_source_bucket("ghcr.io/acme/private:latest"),
470+
"undefined"
471+
);
472+
assert_eq!(deny_group_bucket("host=private.example"), "unknown");
473+
}
474+
475+
#[test]
476+
fn telemetry_validation_rejects_schema_invalid_values() {
477+
assert_eq!(lifecycle_resource("gateway"), None);
478+
assert_eq!(lifecycle_operation("restart"), None);
479+
assert_eq!(policy_decision_operation("merge_internal_rule"), None);
480+
assert_eq!(telemetry_outcome("partial"), None);
481+
assert!(!valid_count(MAX_TELEMETRY_INTEGER + 1));
482+
}
483+
484+
#[test]
485+
fn activity_groups_are_sanitized_and_aggregated() {
486+
let rows = sanitize_denials_by_group([
487+
("connect".to_string(), 1),
488+
("connect_policy".to_string(), 2),
489+
("host=private.example".to_string(), 3),
490+
])
491+
.expect("rows should sanitize");
492+
493+
assert_eq!(rows.get("connect_policy"), Some(&3));
494+
assert_eq!(rows.get("unknown"), Some(&3));
495+
}
325496
}

crates/openshell-sandbox/src/activity_aggregator.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use tracing::debug;
1010

1111
pub const ACTIVITY_EVENT_QUEUE_CAPACITY: usize = 1024;
1212
const ACTIVITY_FLUSH_QUEUE_CAPACITY: usize = 1;
13+
pub const DEFAULT_ACTIVITY_FLUSH_INTERVAL_SECS: u64 = 10;
1314

1415
#[derive(Debug, Clone)]
1516
pub struct ActivityEvent {
@@ -120,6 +121,13 @@ pub fn try_record_activity(tx: &ActivitySender, denied: bool, deny_group: &'stat
120121
tx.try_send(ActivityEvent { denied, deny_group }).is_ok()
121122
}
122123

124+
pub fn activity_flush_interval_secs_from_env(value: Option<&str>) -> u64 {
125+
value
126+
.and_then(|value| value.parse::<u64>().ok())
127+
.filter(|value| *value > 0)
128+
.unwrap_or(DEFAULT_ACTIVITY_FLUSH_INTERVAL_SECS)
129+
}
130+
123131
fn queue_flush_summary(
124132
tx: &mpsc::Sender<FlushableActivitySummary>,
125133
summary: FlushableActivitySummary,
@@ -195,4 +203,21 @@ mod tests {
195203
assert!(queue_flush_summary(&tx, summary.clone()));
196204
assert!(!queue_flush_summary(&tx, summary));
197205
}
206+
207+
#[test]
208+
fn activity_flush_interval_uses_positive_values_only() {
209+
assert_eq!(
210+
activity_flush_interval_secs_from_env(None),
211+
DEFAULT_ACTIVITY_FLUSH_INTERVAL_SECS
212+
);
213+
assert_eq!(
214+
activity_flush_interval_secs_from_env(Some("not-a-number")),
215+
DEFAULT_ACTIVITY_FLUSH_INTERVAL_SECS
216+
);
217+
assert_eq!(
218+
activity_flush_interval_secs_from_env(Some("0")),
219+
DEFAULT_ACTIVITY_FLUSH_INTERVAL_SECS
220+
);
221+
assert_eq!(activity_flush_interval_secs_from_env(Some("5")), 5);
222+
}
198223
}

0 commit comments

Comments
 (0)