66use chrono:: { SecondsFormat , Utc } ;
77use reqwest:: blocking:: Client ;
88use serde_json:: { Value , json} ;
9+ use std:: collections:: BTreeMap ;
910use std:: sync:: { OnceLock , mpsc} ;
1011use std:: thread;
1112use std:: time:: Duration ;
1213
1314const TELEMETRY_EVENT_QUEUE_CAPACITY : usize = 1024 ;
15+ const MAX_TELEMETRY_INTEGER : u64 = 9_223_372_036_854_775_807 ;
1416const CLIENT_ID : & str = "415437562476676" ;
1517const DEFAULT_ENDPOINT : & str = "https://events.telemetry.data-uat.nvidia.com/v1.1/events/json" ;
1618const EVENT_SCHEMA_VERSION : & str = "2.0" ;
@@ -24,10 +26,11 @@ static TELEMETRY_SENDER: OnceLock<Option<mpsc::SyncSender<TelemetryEvent>>> = On
2426struct 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
119122fn 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
143146fn 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
164168pub 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
176189pub 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
208235pub 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) ]
257393mod 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}
0 commit comments