1use serde::{Deserialize, Serialize};
36use sha2::{Digest, Sha256};
37
38use crate::agents::DeliberationPhase;
39
40pub const TELEMETRY_AGENT_PREFIX: &str = "telemetry.agent";
43
44pub const TRACE_ID_LEN: usize = 32;
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct TelemetryConfig {
60 #[serde(default = "default_enabled")]
63 pub enabled: bool,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub endpoints: Vec<TelemetryEndpointConfig>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct TelemetryEndpointConfig {
79 pub name: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub nats_url: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub creds: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub subject_prefix: Option<String>,
98}
99
100impl Default for TelemetryConfig {
101 fn default() -> Self {
102 Self {
103 enabled: true,
104 endpoints: Vec::new(),
105 }
106 }
107}
108
109fn default_enabled() -> bool {
110 true
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum TelemetrySource {
119 Agent {
121 agent_id: String,
123 },
124}
125
126impl TelemetrySource {
127 pub fn agent(agent_id: impl Into<String>) -> Result<Self, String> {
140 let agent_id = agent_id.into();
141 crate::nats_utils::validate_nats_name(&agent_id, "agent_id")?;
142 Ok(TelemetrySource::Agent { agent_id })
143 }
144
145 pub fn subject(&self, kind: &str, custom_prefix: Option<&str>) -> Result<String, String> {
156 if let Some(prefix) = custom_prefix {
157 for segment in prefix.split('.') {
158 crate::nats_utils::validate_nats_name(segment, "telemetry custom_prefix segment")?;
159 }
160 }
161 match self {
162 TelemetrySource::Agent { agent_id } => {
163 crate::nats_utils::validate_nats_name(agent_id, "agent_id")?;
164 let prefix = custom_prefix.unwrap_or(TELEMETRY_AGENT_PREFIX);
165 Ok(format!("{prefix}.{agent_id}.{kind}"))
166 }
167 }
168 }
169}
170
171pub fn derive_trace_id(
187 job_id: &str,
188 round: u32,
189 phase: DeliberationPhase,
190 agent_id: &str,
191) -> String {
192 let input = format!(
193 "{}:{job_id}|{round}|{}|{}:{agent_id}",
194 job_id.len(),
195 phase.as_str(),
196 agent_id.len(),
197 );
198 let digest = Sha256::digest(input.as_bytes());
199 let mut out = String::with_capacity(TRACE_ID_LEN);
200 for byte in digest.iter().take(TRACE_ID_LEN / 2) {
201 use std::fmt::Write;
202 write!(out, "{byte:02x}").expect("writing to String never fails");
203 }
204 debug_assert_eq!(out.len(), TRACE_ID_LEN);
205 out
206}
207
208pub fn trace_id_for(job_id: &str, round: u32, phase: DeliberationPhase, agent_id: &str) -> String {
212 derive_trace_id(job_id, round, phase, agent_id)
213}
214
215fn session_less_trace_id(agent_id: &str) -> String {
223 let uuid = uuid::Uuid::new_v4();
224 let input = format!("nosess|{}:{agent_id}|{}", agent_id.len(), uuid.as_simple());
225 let digest = Sha256::digest(input.as_bytes());
226 let mut out = String::with_capacity(TRACE_ID_LEN);
227 for byte in digest.iter().take(TRACE_ID_LEN / 2) {
228 use std::fmt::Write;
229 write!(out, "{byte:02x}").expect("writing to String never fails");
230 }
231 debug_assert_eq!(out.len(), TRACE_ID_LEN);
232 out
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
249pub struct AgentEventCommon {
250 pub agent_id: String,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub job_id: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub round: Option<u32>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub phase: Option<DeliberationPhase>,
257 pub ts: i64,
259 pub trace_id: String,
261}
262
263#[derive(Debug, Clone)]
274pub struct TelemetryContext {
275 agent_id: String,
276 job_id: Option<String>,
277 round: Option<u32>,
278 phase: Option<DeliberationPhase>,
279 trace_id: String,
280}
281
282impl TelemetryContext {
283 pub fn new(
299 agent_id: &str,
300 job_id: Option<&str>,
301 round: Option<u32>,
302 phase: Option<DeliberationPhase>,
303 ) -> Self {
304 let trace_id = match (job_id, round, phase) {
305 (Some(j), Some(r), Some(p)) => derive_trace_id(j, r, p, agent_id),
306 _ => session_less_trace_id(agent_id),
307 };
308 Self {
309 agent_id: agent_id.to_string(),
310 job_id: job_id.map(|s| s.to_string()),
311 round,
312 phase,
313 trace_id,
314 }
315 }
316
317 pub fn common(&self) -> AgentEventCommon {
319 AgentEventCommon {
320 agent_id: self.agent_id.clone(),
321 job_id: self.job_id.clone(),
322 round: self.round,
323 phase: self.phase,
324 ts: chrono::Utc::now().timestamp_millis(),
325 trace_id: self.trace_id.clone(),
326 }
327 }
328}
329
330#[macro_export]
343macro_rules! emit_event {
344 ($emitter:expr, $ctx:expr, $variant:ident { $($field:ident $(: $value:expr)?),* $(,)? }) => {
349 if let Some(emitter) = $emitter {
350 let event = $crate::telemetry::TelemetryEvent::$variant($crate::telemetry::$variant {
351 common: $ctx.common(),
352 $($field $(: $value)?),*
353 });
354 emitter.emit(&event);
355 }
356 };
357}
358
359#[macro_export]
380macro_rules! emit_for {
381 ($context:expr, $variant:ident { $($field:ident $(: $value:expr)?),* $(,)? }) => {
382 if let Some(ref emitter) = $context.telemetry {
383 let envelope = $context.telemetry_for();
384 let event = $crate::telemetry::TelemetryEvent::$variant($crate::telemetry::$variant {
385 common: envelope.common(),
386 $($field $(: $value)?),*
387 });
388 emitter.emit(&event);
389 }
390 };
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub enum LlmErrorClass {
401 Transport,
402 RateLimit,
403 PaymentRequired,
404 ServerError,
405 ContextOverflow,
406 Parse,
407 Other,
408}
409
410pub use crate::llms::LlmError;
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum FinishReason {
421 Stop,
422 Length,
423 ToolCalls,
424 Error,
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum RetryReason {
431 EmptyContent,
432 SchemaError,
433 Truncated,
434 HallucinatedTool,
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "snake_case")]
440pub enum TaskFailureClass {
441 LlmExhausted,
442 ToolError,
443 Timeout,
444 ContextOverflow,
445 ParseRetryExhausted,
446 EmptyContentAfterRetries,
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "snake_case")]
452pub enum NatsConnectionState {
453 Connected,
454 Disconnected,
455 Reconnecting,
456 Closed,
457}
458
459impl From<&async_nats::connection::State> for NatsConnectionState {
460 fn from(s: &async_nats::connection::State) -> Self {
461 match s {
462 async_nats::connection::State::Connected => Self::Connected,
463 async_nats::connection::State::Disconnected => Self::Disconnected,
464 async_nats::connection::State::Pending => Self::Reconnecting,
465 }
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
474pub struct LlmRequestStart {
475 #[serde(flatten)]
476 pub common: AgentEventCommon,
477 pub request_id: String,
481 pub model: String,
482 pub provider_id: String,
483 pub attempt: u32,
484 pub estimated_input_tokens: u32,
485 #[serde(default)]
488 pub context_utilization_pct: f64,
489 #[serde(default)]
491 pub recent_tool_output_bytes: u64,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
495pub struct LlmRequestComplete {
496 #[serde(flatten)]
497 pub common: AgentEventCommon,
498 pub request_id: String,
499 pub latency_ms: u64,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub ttft_ms: Option<u64>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub generation_ms: Option<u64>,
507 pub input_tokens: u32,
508 pub output_tokens: u32,
509 #[serde(default)]
510 pub reasoning_tokens: u32,
511 #[serde(default)]
512 pub cached_tokens: u32,
513 #[serde(default)]
514 pub cost_usd: f64,
515 pub finish_reason: FinishReason,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub provider_backend: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub claim_assessments_emitted: Option<u32>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub disagreements_emitted: Option<u32>,
524 #[serde(default)]
528 pub messages_chars: u32,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub max_tokens_requested: Option<u32>,
536 #[serde(default)]
540 pub response_chars: u32,
541 #[serde(default)]
546 pub tool_calls_emitted: u32,
547 #[serde(default)]
550 pub max_tokens_shrunk_to_floor: bool,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub available_space_at_dispatch: Option<u32>,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
559pub struct LlmRequestFailed {
560 #[serde(flatten)]
561 pub common: AgentEventCommon,
562 pub request_id: String,
563 pub error_class: LlmErrorClass,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub http_status: Option<u16>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub retry_after_ms: Option<u64>,
568 pub latency_ms: u64,
569 pub provider_id: String,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub provider_backend: Option<String>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
579pub struct LlmRequestStalled {
580 #[serde(flatten)]
581 pub common: AgentEventCommon,
582 pub request_id: String,
583 pub elapsed_ms: u64,
584 pub ttft_received: bool,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub last_token_ms: Option<u64>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
590pub struct ToolCallExecuted {
591 #[serde(flatten)]
592 pub common: AgentEventCommon,
593 pub tool_name: String,
594 pub latency_ms: u64,
595 pub success: bool,
596 #[serde(default)]
598 pub output_bytes: u64,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub output_tokens_estimated: Option<u32>,
603 #[serde(default)]
605 pub truncated: bool,
606 #[serde(default)]
608 pub paginated: bool,
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
617pub struct DeliberationContextAssembled {
618 #[serde(flatten)]
619 pub common: AgentEventCommon,
620 #[serde(default)]
622 pub scratchpad_loaded_chars: u32,
623 #[serde(default)]
625 pub scratchpad_written: bool,
626 #[serde(default)]
627 pub scratchpad_written_chars: u32,
628 #[serde(default)]
630 pub prior_own_proposal_included: bool,
631 #[serde(default)]
632 pub prior_score_included: bool,
633 #[serde(default)]
634 pub prior_critiques_count: u32,
635 #[serde(default)]
637 pub candidates_count: u32,
638 #[serde(default)]
639 pub previous_round_matrix_included: bool,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
643pub struct RetryLoopAttempt {
644 #[serde(flatten)]
645 pub common: AgentEventCommon,
646 pub attempt: u32,
647 pub reason: RetryReason,
648 pub cumulative_latency_ms: u64,
649 #[serde(default)]
651 pub cumulative_cost_usd: f64,
652 #[serde(default)]
653 pub cumulative_input_tokens: u32,
654 #[serde(default)]
655 pub cumulative_output_tokens: u32,
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
659pub struct TaskAccepted {
660 #[serde(flatten)]
661 pub common: AgentEventCommon,
662 pub dispatch_delay_ms: u64,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub task_publish_ts: Option<i64>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub job_age_at_accept_ms: Option<i64>,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
674pub struct TaskCompleted {
675 #[serde(flatten)]
676 pub common: AgentEventCommon,
677 pub duration_ms: u64,
678 pub dispatch_delay_ms: u64,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub queue_wait_ms: Option<u64>,
685 pub phase_budget_remaining_ms: i64,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub llm_attempts: Option<u32>,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub tool_call_count: Option<u32>,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub pending_publish_depth: Option<u32>,
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
703pub struct TaskFailed {
704 #[serde(flatten)]
705 pub common: AgentEventCommon,
706 pub duration_ms: u64,
707 pub dispatch_delay_ms: u64,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub queue_wait_ms: Option<u64>,
710 pub phase_budget_remaining_ms: i64,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub llm_attempts: Option<u32>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub tool_call_count: Option<u32>,
715 pub failure_class: TaskFailureClass,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub pending_publish_depth: Option<u32>,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
722pub struct NatsConnectionStateChanged {
723 #[serde(flatten)]
724 pub common: AgentEventCommon,
725 pub state: NatsConnectionState,
726 pub reconnects_so_far: u32,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub pending_publish_depth: Option<u32>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub buffer_bytes: Option<u64>,
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
758pub struct PromptExposureDetected {
759 #[serde(flatten)]
760 pub common: AgentEventCommon,
761 pub terminal_tool: String,
765 pub blocked: bool,
768 pub hit_count: u32,
772 pub response_length_chars: u32,
775 pub suspicion_score: f64,
780 pub xml_tag_hits: u32,
781 pub tool_name_hits: u32,
782 pub instruction_hits: u32,
783 pub wrong_acronym_hits: u32,
784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
791 pub sample_hits: Vec<String>,
792}
793
794impl PromptExposureDetected {
795 const ALLOWED_PREFIXES: &'static [&'static str] =
799 &["xml-tag ", "tool-name ", "instruction ", "wrong-acronym "];
800
801 pub fn validate(&self) -> Result<(), String> {
811 let sum = self
812 .xml_tag_hits
813 .saturating_add(self.tool_name_hits)
814 .saturating_add(self.instruction_hits)
815 .saturating_add(self.wrong_acronym_hits);
816 if self.hit_count != sum {
817 return Err(format!(
818 "hit_count {} != sum of category hits {} \
819 (xml={}: tool={}: instruction={}: acronym={})",
820 self.hit_count,
821 sum,
822 self.xml_tag_hits,
823 self.tool_name_hits,
824 self.instruction_hits,
825 self.wrong_acronym_hits
826 ));
827 }
828 for (i, hit) in self.sample_hits.iter().enumerate() {
832 if hit.len() > 64 {
833 return Err(format!(
834 "sample_hits[{i}] exceeds 64 chars ({}); \
835 may contain raw content",
836 hit.len()
837 ));
838 }
839 if !Self::ALLOWED_PREFIXES.iter().any(|p| hit.starts_with(p)) {
841 return Err(format!(
842 "sample_hits[{i}] does not start with a known dictionary prefix: {hit:?}"
843 ));
844 }
845 }
846 Ok(())
847 }
848}
849
850#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
858pub struct RecentToolOutput {
859 pub tool: String,
860 pub bytes: u64,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
866pub struct ContextEmergencyShrink {
867 #[serde(flatten)]
868 pub common: AgentEventCommon,
869 pub available_space: u32,
870 pub requested_max: u32,
871 pub floor_used: u32,
873 pub estimated_input: u32,
874 pub context_window: u32,
875 #[serde(default)]
879 pub recent_tool_outputs: Vec<RecentToolOutput>,
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
888pub struct ClaudeSubprocessSpawn {
889 #[serde(flatten)]
890 pub common: AgentEventCommon,
891 pub session_id: String,
893 pub lock_present_at_spawn: bool,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
899pub struct ClaudeSubprocessExit {
900 #[serde(flatten)]
901 pub common: AgentEventCommon,
902 pub session_id: String,
903 pub exit_code: i32,
904 pub wallclock_ms: u64,
905 pub session_lock_released: bool,
907}
908
909#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
911pub struct ClaudeSessionLockCollision {
912 #[serde(flatten)]
913 pub common: AgentEventCommon,
914 pub session_id: String,
915 pub prior_lock_age_secs: u64,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub prior_pid: Option<i32>,
920}
921
922#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
940pub struct ApiError {
941 #[serde(flatten)]
942 pub common: AgentEventCommon,
943 pub http_status: u16,
945 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub error_code: Option<String>,
950 pub endpoint: String,
954 pub method: String,
955 pub duration_ms: u64,
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
962#[serde(tag = "type", rename_all = "snake_case")]
963pub enum TelemetryEvent {
964 LlmRequestStart(LlmRequestStart),
965 LlmRequestComplete(LlmRequestComplete),
966 LlmRequestFailed(LlmRequestFailed),
967 LlmRequestStalled(LlmRequestStalled),
968 ToolCallExecuted(ToolCallExecuted),
969 DeliberationContextAssembled(DeliberationContextAssembled),
970 RetryLoopAttempt(RetryLoopAttempt),
971 TaskAccepted(TaskAccepted),
972 TaskCompleted(TaskCompleted),
973 TaskFailed(TaskFailed),
974 #[serde(rename = "nats_connection_state")]
975 NatsConnectionStateChanged(NatsConnectionStateChanged),
976 PromptExposureDetected(PromptExposureDetected),
977 ApiError(ApiError),
978 ContextEmergencyShrink(ContextEmergencyShrink),
979 ClaudeSubprocessSpawn(ClaudeSubprocessSpawn),
980 ClaudeSubprocessExit(ClaudeSubprocessExit),
981 ClaudeSessionLockCollision(ClaudeSessionLockCollision),
982}
983
984impl TelemetryEvent {
985 pub fn kind(&self) -> &'static str {
989 match self {
990 TelemetryEvent::LlmRequestStart(_) => "llm_request_start",
991 TelemetryEvent::LlmRequestComplete(_) => "llm_request_complete",
992 TelemetryEvent::LlmRequestFailed(_) => "llm_request_failed",
993 TelemetryEvent::LlmRequestStalled(_) => "llm_request_stalled",
994 TelemetryEvent::ToolCallExecuted(_) => "tool_call_executed",
995 TelemetryEvent::DeliberationContextAssembled(_) => "deliberation_context_assembled",
996 TelemetryEvent::RetryLoopAttempt(_) => "retry_loop_attempt",
997 TelemetryEvent::TaskAccepted(_) => "task_accepted",
998 TelemetryEvent::TaskCompleted(_) => "task_completed",
999 TelemetryEvent::TaskFailed(_) => "task_failed",
1000 TelemetryEvent::NatsConnectionStateChanged(_) => "nats_connection_state",
1001 TelemetryEvent::PromptExposureDetected(_) => "prompt_exposure_detected",
1002 TelemetryEvent::ApiError(_) => "api_error",
1003 TelemetryEvent::ContextEmergencyShrink(_) => "context_emergency_shrink",
1004 TelemetryEvent::ClaudeSubprocessSpawn(_) => "claude_subprocess_spawn",
1005 TelemetryEvent::ClaudeSubprocessExit(_) => "claude_subprocess_exit",
1006 TelemetryEvent::ClaudeSessionLockCollision(_) => "claude_session_lock_collision",
1007 }
1008 }
1009
1010 pub fn agent_id(&self) -> &str {
1012 match self {
1013 TelemetryEvent::LlmRequestStart(e) => &e.common.agent_id,
1014 TelemetryEvent::LlmRequestComplete(e) => &e.common.agent_id,
1015 TelemetryEvent::LlmRequestFailed(e) => &e.common.agent_id,
1016 TelemetryEvent::LlmRequestStalled(e) => &e.common.agent_id,
1017 TelemetryEvent::ToolCallExecuted(e) => &e.common.agent_id,
1018 TelemetryEvent::DeliberationContextAssembled(e) => &e.common.agent_id,
1019 TelemetryEvent::RetryLoopAttempt(e) => &e.common.agent_id,
1020 TelemetryEvent::TaskAccepted(e) => &e.common.agent_id,
1021 TelemetryEvent::TaskCompleted(e) => &e.common.agent_id,
1022 TelemetryEvent::TaskFailed(e) => &e.common.agent_id,
1023 TelemetryEvent::NatsConnectionStateChanged(e) => &e.common.agent_id,
1024 TelemetryEvent::PromptExposureDetected(e) => &e.common.agent_id,
1025 TelemetryEvent::ApiError(e) => &e.common.agent_id,
1026 TelemetryEvent::ContextEmergencyShrink(e) => &e.common.agent_id,
1027 TelemetryEvent::ClaudeSubprocessSpawn(e) => &e.common.agent_id,
1028 TelemetryEvent::ClaudeSubprocessExit(e) => &e.common.agent_id,
1029 TelemetryEvent::ClaudeSessionLockCollision(e) => &e.common.agent_id,
1030 }
1031 }
1032}
1033
1034fn source_agent_matches(source: &TelemetrySource, event: &TelemetryEvent) -> bool {
1045 match source {
1046 TelemetrySource::Agent { agent_id: src_id } => event.agent_id() == *src_id,
1047 }
1048}
1049
1050#[derive(Clone)]
1065pub struct TelemetryEmitter {
1066 client: async_nats::Client,
1067 source: TelemetrySource,
1068 custom_prefix: Option<String>,
1069 dropped: std::sync::Arc<std::sync::atomic::AtomicU64>,
1070}
1071
1072impl std::fmt::Debug for TelemetryEmitter {
1073 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1074 f.debug_struct("TelemetryEmitter")
1075 .field("source", &self.source)
1076 .field("custom_prefix", &self.custom_prefix)
1077 .field("dropped", &self.dropped_count())
1078 .finish()
1079 }
1080}
1081
1082impl TelemetryEmitter {
1083 pub fn new(client: async_nats::Client, source: TelemetrySource) -> Self {
1084 Self {
1085 client,
1086 source,
1087 custom_prefix: None,
1088 dropped: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
1089 }
1090 }
1091
1092 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
1094 self.custom_prefix = Some(prefix.into());
1095 self
1096 }
1097
1098 pub fn dropped_count(&self) -> u64 {
1102 self.dropped.load(std::sync::atomic::Ordering::Relaxed)
1103 }
1104
1105 pub fn emit(&self, event: &TelemetryEvent) {
1118 if !source_agent_matches(&self.source, event) {
1121 let src_id = match &self.source {
1122 TelemetrySource::Agent { agent_id } => agent_id.as_str(),
1123 };
1124 tracing::warn!(
1125 event_kind = event.kind(),
1126 emitter_agent_id = src_id,
1127 event_agent_id = event.agent_id(),
1128 "dropping telemetry event: payload agent_id does not match emitter"
1129 );
1130 self.dropped
1131 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1132 return;
1133 }
1134
1135 if let TelemetryEvent::PromptExposureDetected(detected) = event {
1138 if let Err(e) = detected.validate() {
1139 tracing::warn!(
1140 error = %e,
1141 "dropping invalid PromptExposureDetected event"
1142 );
1143 self.dropped
1144 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1145 return;
1146 }
1147 }
1148
1149 let subject = match self
1150 .source
1151 .subject(event.kind(), self.custom_prefix.as_deref())
1152 {
1153 Ok(s) => s,
1154 Err(_) => {
1155 self.dropped
1156 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1157 return;
1158 }
1159 };
1160 let payload = match serde_json::to_vec(event) {
1161 Ok(bytes) => bytes,
1162 Err(_) => {
1163 self.dropped
1164 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1165 return;
1166 }
1167 };
1168 let handle = match tokio::runtime::Handle::try_current() {
1179 Ok(h) => h,
1180 Err(_) => {
1181 self.dropped
1182 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1183 return;
1184 }
1185 };
1186 let client = self.client.clone();
1187 let dropped = self.dropped.clone();
1188 handle.spawn(async move {
1189 if client.publish(subject, payload.into()).await.is_err() {
1190 dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1191 }
1192 });
1193 }
1194}
1195
1196#[derive(Clone)]
1214pub struct TelemetryEmitterMux {
1215 endpoints: Vec<(String, TelemetryEmitter)>,
1216}
1217
1218impl std::fmt::Debug for TelemetryEmitterMux {
1219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1220 let names: Vec<&str> = self.endpoints.iter().map(|(n, _)| n.as_str()).collect();
1221 f.debug_struct("TelemetryEmitterMux")
1222 .field("endpoints", &names)
1223 .field("dropped", &self.dropped_count())
1224 .finish()
1225 }
1226}
1227
1228#[derive(Debug, Clone, PartialEq, Eq)]
1230pub enum TelemetryMuxError {
1231 DuplicateNames(Vec<String>),
1237}
1238
1239impl std::fmt::Display for TelemetryMuxError {
1240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1241 match self {
1242 Self::DuplicateNames(names) => write!(
1243 f,
1244 "telemetry endpoint names must be unique; duplicates: {names:?}"
1245 ),
1246 }
1247 }
1248}
1249
1250impl std::error::Error for TelemetryMuxError {}
1251
1252fn validate_endpoint_names(names: &[String]) -> Result<(), TelemetryMuxError> {
1256 let mut seen = std::collections::HashSet::with_capacity(names.len());
1257 let mut dups: Vec<String> = Vec::new();
1258 for n in names {
1259 if !seen.insert(n.as_str()) && !dups.iter().any(|d| d == n) {
1260 dups.push(n.clone());
1261 }
1262 }
1263 if dups.is_empty() {
1264 Ok(())
1265 } else {
1266 Err(TelemetryMuxError::DuplicateNames(dups))
1267 }
1268}
1269
1270impl TelemetryEmitterMux {
1271 pub fn new(endpoints: Vec<(String, TelemetryEmitter)>) -> Result<Self, TelemetryMuxError> {
1277 let names: Vec<String> = endpoints.iter().map(|(n, _)| n.clone()).collect();
1278 validate_endpoint_names(&names)?;
1279 Ok(Self { endpoints })
1280 }
1281
1282 pub fn single(name: impl Into<String>, emitter: TelemetryEmitter) -> Self {
1287 Self {
1288 endpoints: vec![(name.into(), emitter)],
1289 }
1290 }
1291
1292 pub fn len(&self) -> usize {
1294 self.endpoints.len()
1295 }
1296
1297 pub fn is_empty(&self) -> bool {
1301 self.endpoints.is_empty()
1302 }
1303
1304 pub fn endpoint_names(&self) -> Vec<&str> {
1307 self.endpoints.iter().map(|(n, _)| n.as_str()).collect()
1308 }
1309
1310 pub fn dropped_count(&self) -> u64 {
1312 self.endpoints.iter().map(|(_, e)| e.dropped_count()).sum()
1313 }
1314
1315 pub fn emit(&self, event: &TelemetryEvent) {
1318 for (_, emitter) in &self.endpoints {
1319 emitter.emit(event);
1320 }
1321 }
1322
1323 pub fn emit_for(&self, name: &str, event: &TelemetryEvent) {
1331 if let Some((_, emitter)) = self.endpoints.iter().find(|(n, _)| n == name) {
1332 emitter.emit(event);
1333 } else {
1334 tracing::debug!(
1335 requested = %name,
1336 available = ?self.endpoint_names(),
1337 "telemetry emit_for: endpoint name not configured, dropping event"
1338 );
1339 }
1340 }
1341}
1342
1343#[derive(Debug, thiserror::Error)]
1345pub enum TelemetryConnectError {
1346 #[error("invalid agent_id: {0}")]
1348 InvalidAgentId(String),
1349 #[error("telemetry endpoint `{name}` missing nats_url")]
1354 MissingNatsUrl {
1355 name: String,
1357 },
1358 #[error("connect to NATS for telemetry endpoint `{name}` failed: {source}")]
1360 NatsConnect {
1361 name: String,
1363 #[source]
1365 source: anyhow::Error,
1366 },
1367 #[error("mux construction: {0}")]
1369 Mux(#[from] TelemetryMuxError),
1370}
1371
1372pub async fn connect_endpoints(
1386 config: &TelemetryConfig,
1387 agent_id: &str,
1388) -> Result<Option<TelemetryEmitterMux>, TelemetryConnectError> {
1389 if !config.enabled || config.endpoints.is_empty() {
1390 return Ok(None);
1391 }
1392 let source = TelemetrySource::agent(agent_id)
1393 .map_err(|_| TelemetryConnectError::InvalidAgentId(agent_id.to_string()))?;
1394
1395 let mut emitters = Vec::with_capacity(config.endpoints.len());
1396 for ep in &config.endpoints {
1397 let url = ep
1398 .nats_url
1399 .as_deref()
1400 .ok_or_else(|| TelemetryConnectError::MissingNatsUrl {
1401 name: ep.name.clone(),
1402 })?;
1403 let auth = ep.creds.as_ref().map(|path| crate::nats_utils::NatsAuth {
1404 creds_file: Some(path.clone()),
1405 ..Default::default()
1406 });
1407 let client = crate::nats_utils::connect_nats(url, auth.as_ref())
1408 .await
1409 .map_err(|e| TelemetryConnectError::NatsConnect {
1410 name: ep.name.clone(),
1411 source: e,
1412 })?;
1413 let mut emitter = TelemetryEmitter::new(client, source.clone());
1414 if let Some(prefix) = &ep.subject_prefix {
1415 emitter = emitter.with_prefix(prefix);
1416 }
1417 emitters.push((ep.name.clone(), emitter));
1418 }
1419 Ok(Some(TelemetryEmitterMux::new(emitters)?))
1420}
1421
1422pub fn redact_content(input: &str, max_length: usize) -> String {
1441 if input.is_empty() {
1442 return "<empty>".to_string();
1443 }
1444
1445 let lower = input.to_lowercase();
1449 let sensitive_words = [
1450 "password",
1451 "api_key",
1452 "secret_key",
1453 "access_key",
1454 "credential",
1455 "bearer",
1456 ];
1457 for word in &sensitive_words {
1458 let mut search = &lower[..];
1461 while let Some(pos) = search.find(word) {
1462 let before_ok = pos == 0 || !search.as_bytes()[pos - 1].is_ascii_alphanumeric();
1463 let after_pos = pos + word.len();
1464 let after_ok =
1465 after_pos >= search.len() || !search.as_bytes()[after_pos].is_ascii_alphanumeric();
1466 if before_ok && after_ok {
1467 return "REDACTED_SENSITIVE".to_string();
1468 }
1469 search = &search[pos + 1..];
1470 }
1471 }
1472
1473 if input.chars().count() <= max_length {
1475 input.to_string()
1476 } else {
1477 let truncate_at = input
1478 .char_indices()
1479 .nth(max_length)
1480 .map(|(i, _)| i)
1481 .unwrap_or(input.len());
1482 format!("{}...", &input[..truncate_at])
1483 }
1484}
1485
1486pub fn redact_error_message(error_msg: &str) -> String {
1488 redact_content(error_msg, 100)
1489}
1490
1491pub fn redact_url(url: &str) -> String {
1496 let base = url.split(['?', '#']).next().unwrap();
1498 if let Some(scheme_end) = base.find("://") {
1501 let scheme = &base[..scheme_end + 3];
1502 let after_scheme = &base[scheme_end + 3..];
1503 if let Some(at_pos) = after_scheme.find('@') {
1504 let slash_pos = after_scheme.find('/');
1507 if slash_pos.is_none_or(|s| s > at_pos) {
1508 return format!("{scheme}{}", &after_scheme[at_pos + 1..]);
1509 }
1510 }
1511 }
1512 base.to_string()
1513}
1514
1515#[cfg(test)]
1520mod tests {
1521 use super::*;
1522
1523 fn sample_agent_common() -> AgentEventCommon {
1524 AgentEventCommon {
1525 agent_id: "CortexB".to_string(),
1526 job_id: Some("job-123".to_string()),
1527 round: Some(3),
1528 phase: Some(DeliberationPhase::Proposing),
1529 ts: 1_776_790_692_747,
1530 trace_id: derive_trace_id("job-123", 3, DeliberationPhase::Proposing, "CortexB"),
1531 }
1532 }
1533
1534 #[test]
1540 fn trace_id_width_is_at_least_128_bits() {
1541 const _: () = assert!(
1542 TRACE_ID_LEN >= 32,
1543 "trace_id must carry at least 128 bits (32 hex chars)"
1544 );
1545 let out = derive_trace_id("job-x", 1, DeliberationPhase::Proposing, "a");
1546 assert_eq!(out.len(), TRACE_ID_LEN);
1547 }
1548
1549 #[test]
1555 fn telemetry_context_trace_id_shape_is_uniform() {
1556 let task_ctx = TelemetryContext::new(
1557 "alice",
1558 Some("job-1"),
1559 Some(2),
1560 Some(DeliberationPhase::Proposing),
1561 );
1562 let task_trace = task_ctx.common().trace_id;
1563 assert_eq!(task_trace.len(), TRACE_ID_LEN);
1564 assert!(task_trace.chars().all(|c| c.is_ascii_hexdigit()));
1565
1566 let sessionless = TelemetryContext::new("alice", None, None, None);
1567 let sl_trace = sessionless.common().trace_id;
1568 assert_eq!(sl_trace.len(), TRACE_ID_LEN);
1569 assert!(sl_trace.chars().all(|c| c.is_ascii_hexdigit()));
1570
1571 let sl2 = TelemetryContext::new("alice", None, None, None);
1574 assert_ne!(sl_trace, sl2.common().trace_id);
1575 }
1576
1577 #[test]
1582 fn empty_mux_is_empty_and_no_op_safe() {
1583 let mux = TelemetryEmitterMux::new(vec![]).expect("empty mux is valid");
1584 assert!(mux.is_empty());
1585 assert_eq!(mux.len(), 0);
1586 assert_eq!(mux.dropped_count(), 0);
1587 assert!(mux.endpoint_names().is_empty());
1588 let evt = TelemetryEvent::TaskAccepted(TaskAccepted {
1591 common: sample_agent_common(),
1592 dispatch_delay_ms: 0,
1593 task_publish_ts: None,
1594 job_age_at_accept_ms: None,
1595 });
1596 mux.emit(&evt);
1597 mux.emit_for("does-not-exist", &evt);
1598 }
1599
1600 #[test]
1601 fn telemetry_endpoint_config_serde_roundtrip() {
1602 let cfg = TelemetryConfig {
1603 enabled: true,
1604 endpoints: vec![
1605 TelemetryEndpointConfig {
1606 name: "service".into(),
1607 nats_url: Some("nats://orch.example.com:4222".into()),
1608 creds: Some("/etc/nsed/agent-service.creds".into()),
1609 subject_prefix: None,
1610 },
1611 TelemetryEndpointConfig {
1612 name: "own".into(),
1613 nats_url: Some("nats://my-grafana.local:4222".into()),
1614 creds: Some("/etc/nsed/agent-own.creds".into()),
1615 subject_prefix: Some("telemetry.agent".into()),
1616 },
1617 ],
1618 };
1619 let json = serde_json::to_string(&cfg).expect("serialise");
1620 let back: TelemetryConfig = serde_json::from_str(&json).expect("deserialise");
1621 assert_eq!(back, cfg);
1622 }
1623
1624 #[test]
1625 fn telemetry_config_endpoints_omitted_defaults_empty() {
1626 let yaml = "enabled: true\n";
1631 let cfg: TelemetryConfig = serde_yaml::from_str(yaml).expect("yaml parse");
1632 assert!(cfg.enabled);
1633 assert!(cfg.endpoints.is_empty());
1634 }
1635
1636 #[test]
1637 fn validate_endpoint_names_rejects_duplicates() {
1638 let err = validate_endpoint_names(&[
1644 "dup".to_string(),
1645 "solo".to_string(),
1646 "dup".to_string(),
1647 "another".to_string(),
1648 "solo".to_string(),
1649 ])
1650 .expect_err("must reject duplicates");
1651 match err {
1652 TelemetryMuxError::DuplicateNames(mut names) => {
1653 names.sort();
1654 assert_eq!(names, vec!["dup".to_string(), "solo".to_string()]);
1655 }
1656 }
1657 }
1658
1659 #[test]
1660 fn validate_endpoint_names_accepts_unique() {
1661 validate_endpoint_names(&["a".to_string(), "b".to_string(), "c".to_string()])
1662 .expect("unique names accepted");
1663 }
1664
1665 #[tokio::test]
1668 async fn connect_endpoints_disabled_yields_none() {
1669 let cfg = TelemetryConfig {
1670 enabled: false,
1671 endpoints: vec![TelemetryEndpointConfig {
1672 name: "wont-connect".into(),
1673 nats_url: Some("nats://does-not-resolve.invalid:4222".into()),
1674 creds: None,
1675 subject_prefix: None,
1676 }],
1677 };
1678 let mux = connect_endpoints(&cfg, "agent-x")
1679 .await
1680 .expect("disabled is not an error");
1681 assert!(mux.is_none());
1682 }
1683
1684 #[tokio::test]
1687 async fn connect_endpoints_empty_endpoints_yields_none() {
1688 let cfg = TelemetryConfig {
1689 enabled: true,
1690 endpoints: vec![],
1691 };
1692 let mux = connect_endpoints(&cfg, "agent-x")
1693 .await
1694 .expect("empty endpoints is not an error");
1695 assert!(mux.is_none());
1696 }
1697
1698 #[tokio::test]
1702 async fn connect_endpoints_missing_nats_url_errors() {
1703 let cfg = TelemetryConfig {
1704 enabled: true,
1705 endpoints: vec![TelemetryEndpointConfig {
1706 name: "no-url".into(),
1707 nats_url: None,
1708 creds: None,
1709 subject_prefix: None,
1710 }],
1711 };
1712 let err = connect_endpoints(&cfg, "agent-x")
1713 .await
1714 .expect_err("missing url must error");
1715 match err {
1716 TelemetryConnectError::MissingNatsUrl { name } => assert_eq!(name, "no-url"),
1717 other => panic!("wrong variant: {other:?}"),
1718 }
1719 }
1720
1721 #[tokio::test]
1724 async fn connect_endpoints_invalid_agent_id_errors() {
1725 let cfg = TelemetryConfig {
1726 enabled: true,
1727 endpoints: vec![TelemetryEndpointConfig {
1728 name: "real".into(),
1729 nats_url: Some("nats://ignored.invalid:4222".into()),
1730 creds: None,
1731 subject_prefix: None,
1732 }],
1733 };
1734 let err = connect_endpoints(&cfg, "bad agent_id with space")
1735 .await
1736 .expect_err("invalid agent_id must error");
1737 assert!(matches!(err, TelemetryConnectError::InvalidAgentId(_)));
1738 }
1739
1740 #[test]
1752 fn trace_id_is_deterministic_and_correct_length() {
1753 let a = derive_trace_id("job-x", 1, DeliberationPhase::Evaluating, "alpha");
1754 let b = derive_trace_id("job-x", 1, DeliberationPhase::Evaluating, "alpha");
1755 assert_eq!(a, b, "same inputs must produce the same trace_id");
1756 assert_eq!(a.len(), TRACE_ID_LEN);
1757 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
1758 }
1759
1760 #[test]
1761 fn trace_id_differs_across_any_input() {
1762 let base = derive_trace_id("j", 1, DeliberationPhase::Proposing, "a");
1763 assert_ne!(
1764 base,
1765 derive_trace_id("j2", 1, DeliberationPhase::Proposing, "a")
1766 );
1767 assert_ne!(
1768 base,
1769 derive_trace_id("j", 2, DeliberationPhase::Proposing, "a")
1770 );
1771 assert_ne!(
1772 base,
1773 derive_trace_id("j", 1, DeliberationPhase::Evaluating, "a")
1774 );
1775 assert_ne!(
1776 base,
1777 derive_trace_id("j", 1, DeliberationPhase::Proposing, "b")
1778 );
1779 }
1780
1781 #[test]
1789 fn trace_id_resists_delimiter_collision_attacks() {
1790 let a = derive_trace_id("ab", 1, DeliberationPhase::Proposing, "c");
1793 let b = derive_trace_id("a", 1, DeliberationPhase::Proposing, "bc");
1794 assert_ne!(a, b, "boundary must be unambiguous");
1795
1796 let c = derive_trace_id("job:1|x", 1, DeliberationPhase::Proposing, "agent");
1800 let d = derive_trace_id("job", 1, DeliberationPhase::Proposing, "1|x:agent");
1801 assert_ne!(c, d, "embedded delimiter must not produce collision");
1802 }
1803
1804 #[test]
1805 fn agent_subject_binds_agent_id_position() {
1806 let evt = TelemetryEvent::TaskAccepted(TaskAccepted {
1807 common: sample_agent_common(),
1808 dispatch_delay_ms: 42,
1809 task_publish_ts: None,
1810 job_age_at_accept_ms: None,
1811 });
1812 let src = TelemetrySource::agent("CortexB").unwrap();
1813 assert_eq!(
1814 src.subject(evt.kind(), None).unwrap(),
1815 "telemetry.agent.CortexB.task_accepted"
1816 );
1817 }
1818
1819 #[test]
1824 fn agent_constructor_rejects_invalid_agent_ids() {
1825 for bad in [
1826 "evil.injection",
1827 "with*wildcard",
1828 "with>wildcard",
1829 "with whitespace",
1830 "with\nnewline",
1831 "",
1832 ] {
1833 assert!(
1834 TelemetrySource::agent(bad).is_err(),
1835 "agent({bad:?}) should be rejected"
1836 );
1837 }
1838 }
1839
1840 #[test]
1841 fn agent_subject_rejects_invalid_agent_id_at_subject_time() {
1842 let evt = TelemetryEvent::TaskAccepted(TaskAccepted {
1843 common: sample_agent_common(),
1844 dispatch_delay_ms: 0,
1845 task_publish_ts: None,
1846 job_age_at_accept_ms: None,
1847 });
1848 let bad = TelemetrySource::Agent {
1849 agent_id: "evil.injection".into(),
1850 };
1851 assert!(bad.subject(evt.kind(), None).is_err());
1852 }
1853
1854 #[test]
1859 fn tokio_runtime_handle_try_current_is_err_off_runtime() {
1860 let handle = std::thread::spawn(|| tokio::runtime::Handle::try_current().is_err())
1861 .join()
1862 .unwrap();
1863 assert!(
1864 handle,
1865 "off-runtime threads must report Err so emit() can degrade to a drop"
1866 );
1867 }
1868
1869 #[test]
1870 fn custom_prefix_validates_each_dot_segment() {
1871 let src = TelemetrySource::agent("CortexB").unwrap();
1872 assert!(
1873 src.subject("task_accepted", Some("tenant.op42.agent"))
1874 .is_ok()
1875 );
1876 assert!(
1877 src.subject("task_accepted", Some("tenant.op*42.agent"))
1878 .is_err()
1879 );
1880 assert!(
1881 src.subject("task_accepted", Some("tenant. .agent"))
1882 .is_err()
1883 );
1884 }
1885
1886 fn sample_prompt_exposure() -> PromptExposureDetected {
1891 PromptExposureDetected {
1892 common: sample_agent_common(),
1893 terminal_tool: "submit_proposal".into(),
1894 blocked: true,
1895 hit_count: 3,
1896 response_length_chars: 1_482,
1897 suspicion_score: 4.76,
1898 xml_tag_hits: 2,
1899 tool_name_hits: 1,
1900 instruction_hits: 0,
1901 wrong_acronym_hits: 0,
1902 sample_hits: vec![
1903 "xml-tag <working_memory>".into(),
1904 "xml-tag <key_findings>".into(),
1905 "tool-name submit_proposal".into(),
1906 ],
1907 }
1908 }
1909
1910 #[test]
1911 fn roundtrip_prompt_exposure_detected() {
1912 let evt = TelemetryEvent::PromptExposureDetected(sample_prompt_exposure());
1913 let json = serde_json::to_string(&evt).unwrap();
1914 let back: TelemetryEvent = serde_json::from_str(&json).unwrap();
1915 assert_eq!(evt, back);
1916 assert!(json.contains("\"type\":\"prompt_exposure_detected\""));
1917 }
1918
1919 #[test]
1920 fn roundtrip_deliberation_context_assembled() {
1921 let evt = TelemetryEvent::DeliberationContextAssembled(DeliberationContextAssembled {
1922 common: sample_agent_common(),
1923 scratchpad_loaded_chars: 1024,
1924 scratchpad_written: true,
1925 scratchpad_written_chars: 412,
1926 prior_own_proposal_included: true,
1927 prior_score_included: true,
1928 prior_critiques_count: 2,
1929 candidates_count: 3,
1930 previous_round_matrix_included: true,
1931 });
1932 let json = serde_json::to_string(&evt).unwrap();
1933 let back: TelemetryEvent = serde_json::from_str(&json).unwrap();
1934 assert_eq!(evt, back);
1935 assert_eq!(evt.kind(), "deliberation_context_assembled");
1936 let v: serde_json::Value = serde_json::to_value(&evt).unwrap();
1939 assert_eq!(v["type"], "deliberation_context_assembled");
1940 assert_eq!(v["scratchpad_written"], true);
1941 assert_eq!(v["candidates_count"], 3);
1942 assert_eq!(v["agent_id"], "CortexB");
1943 }
1944
1945 #[test]
1951 fn prompt_exposure_kind_is_stable() {
1952 let evt = TelemetryEvent::PromptExposureDetected(sample_prompt_exposure());
1953 assert_eq!(evt.kind(), "prompt_exposure_detected");
1954 let src = TelemetrySource::agent("CortexB").unwrap();
1955 assert_eq!(
1956 src.subject(evt.kind(), None).unwrap(),
1957 "telemetry.agent.CortexB.prompt_exposure_detected"
1958 );
1959 }
1960
1961 #[test]
1967 fn prompt_exposure_category_counts_sum_to_hit_count() {
1968 let evt = sample_prompt_exposure();
1969 let sum =
1970 evt.xml_tag_hits + evt.tool_name_hits + evt.instruction_hits + evt.wrong_acronym_hits;
1971 assert_eq!(sum, evt.hit_count);
1972 }
1973
1974 #[test]
1980 fn prompt_exposure_sample_hits_only_dictionary_prefixes() {
1981 let evt = sample_prompt_exposure();
1982 for hit in &evt.sample_hits {
1983 let ok = hit.starts_with("xml-tag ")
1984 || hit.starts_with("tool-name ")
1985 || hit.starts_with("instruction ")
1986 || hit.starts_with("wrong-acronym ");
1987 assert!(
1988 ok,
1989 "sample_hits entry {hit:?} does not start with a known dictionary prefix"
1990 );
1991 }
1992 }
1993
1994 #[test]
2000 fn prompt_exposure_below_threshold_still_roundtrips() {
2001 let evt = TelemetryEvent::PromptExposureDetected(PromptExposureDetected {
2002 blocked: false,
2003 hit_count: 1,
2004 xml_tag_hits: 1,
2005 tool_name_hits: 0,
2006 instruction_hits: 0,
2007 wrong_acronym_hits: 0,
2008 suspicion_score: 0.12,
2009 sample_hits: vec!["xml-tag <strategy>".into()],
2010 ..sample_prompt_exposure()
2011 });
2012 let json = serde_json::to_string(&evt).unwrap();
2013 let back: TelemetryEvent = serde_json::from_str(&json).unwrap();
2014 assert_eq!(evt, back);
2015 }
2016
2017 #[test]
2018 fn roundtrip_task_completed_with_g1_g6_fields() {
2019 let evt = TelemetryEvent::TaskCompleted(TaskCompleted {
2020 common: sample_agent_common(),
2021 duration_ms: 12_000,
2022 dispatch_delay_ms: 40,
2023 queue_wait_ms: Some(5),
2024 phase_budget_remaining_ms: 3_000,
2025 llm_attempts: Some(2),
2026 tool_call_count: Some(1),
2027 pending_publish_depth: Some(0),
2028 });
2029 let json = serde_json::to_string(&evt).unwrap();
2030 let back: TelemetryEvent = serde_json::from_str(&json).unwrap();
2031 assert_eq!(evt, back);
2032 assert!(json.contains("\"type\":\"task_completed\""));
2034 }
2035
2036 #[test]
2037 fn roundtrip_llm_request_complete_with_ttft_and_evaluator_counters() {
2038 let evt = TelemetryEvent::LlmRequestComplete(LlmRequestComplete {
2039 common: AgentEventCommon {
2040 phase: Some(DeliberationPhase::Evaluating),
2041 ..sample_agent_common()
2042 },
2043 request_id: "req-1".into(),
2044 latency_ms: 4_200,
2045 ttft_ms: Some(180),
2046 generation_ms: Some(4_020),
2047 input_tokens: 1_200,
2048 output_tokens: 350,
2049 reasoning_tokens: 120,
2050 cached_tokens: 0,
2051 cost_usd: 0.0041,
2052 finish_reason: FinishReason::Stop,
2053 provider_backend: Some("openrouter/deepinfra".into()),
2054 claim_assessments_emitted: Some(12),
2055 disagreements_emitted: Some(2),
2056 messages_chars: 4_800,
2057 max_tokens_requested: Some(2_000),
2058 response_chars: 1_400,
2059 tool_calls_emitted: 0,
2060 max_tokens_shrunk_to_floor: false,
2061 available_space_at_dispatch: None,
2062 });
2063 let json = serde_json::to_string(&evt).unwrap();
2064 let back: TelemetryEvent = serde_json::from_str(&json).unwrap();
2065 assert_eq!(evt, back);
2066 }
2067
2068 #[test]
2069 fn roundtrip_retry_loop_attempt_cost_fields() {
2070 let evt = TelemetryEvent::RetryLoopAttempt(RetryLoopAttempt {
2071 common: sample_agent_common(),
2072 attempt: 3,
2073 reason: RetryReason::SchemaError,
2074 cumulative_latency_ms: 18_400,
2075 cumulative_cost_usd: 0.0127,
2076 cumulative_input_tokens: 3_200,
2077 cumulative_output_tokens: 900,
2078 });
2079 let json = serde_json::to_string(&evt).unwrap();
2080 let back: TelemetryEvent = serde_json::from_str(&json).unwrap();
2081 assert_eq!(evt, back);
2082 }
2083
2084 #[test]
2085 fn event_kind_covers_every_variant() {
2086 let samples: Vec<TelemetryEvent> = vec![
2090 TelemetryEvent::LlmRequestStart(LlmRequestStart {
2091 common: sample_agent_common(),
2092 request_id: "r".into(),
2093 model: "m".into(),
2094 provider_id: "p".into(),
2095 attempt: 1,
2096 estimated_input_tokens: 0,
2097 context_utilization_pct: 0.0,
2098 recent_tool_output_bytes: 0,
2099 }),
2100 TelemetryEvent::LlmRequestComplete(LlmRequestComplete {
2101 common: sample_agent_common(),
2102 request_id: "r".into(),
2103 latency_ms: 0,
2104 ttft_ms: None,
2105 generation_ms: None,
2106 input_tokens: 0,
2107 output_tokens: 0,
2108 reasoning_tokens: 0,
2109 cached_tokens: 0,
2110 cost_usd: 0.0,
2111 finish_reason: FinishReason::Stop,
2112 provider_backend: None,
2113 claim_assessments_emitted: None,
2114 disagreements_emitted: None,
2115 messages_chars: 0,
2116 max_tokens_requested: None,
2117 response_chars: 0,
2118 tool_calls_emitted: 0,
2119 max_tokens_shrunk_to_floor: false,
2120 available_space_at_dispatch: None,
2121 }),
2122 TelemetryEvent::LlmRequestFailed(LlmRequestFailed {
2123 common: sample_agent_common(),
2124 request_id: "r".into(),
2125 error_class: LlmErrorClass::Transport,
2126 http_status: None,
2127 retry_after_ms: None,
2128 latency_ms: 0,
2129 provider_id: "p".into(),
2130 provider_backend: None,
2131 }),
2132 TelemetryEvent::LlmRequestStalled(LlmRequestStalled {
2133 common: sample_agent_common(),
2134 request_id: "r".into(),
2135 elapsed_ms: 0,
2136 ttft_received: false,
2137 last_token_ms: None,
2138 }),
2139 TelemetryEvent::ToolCallExecuted(ToolCallExecuted {
2140 common: sample_agent_common(),
2141 tool_name: "scratchpad".into(),
2142 latency_ms: 0,
2143 success: true,
2144 output_bytes: 0,
2145 output_tokens_estimated: None,
2146 truncated: false,
2147 paginated: false,
2148 }),
2149 TelemetryEvent::DeliberationContextAssembled(DeliberationContextAssembled {
2150 common: sample_agent_common(),
2151 scratchpad_loaded_chars: 0,
2152 scratchpad_written: false,
2153 scratchpad_written_chars: 0,
2154 prior_own_proposal_included: false,
2155 prior_score_included: false,
2156 prior_critiques_count: 0,
2157 candidates_count: 0,
2158 previous_round_matrix_included: false,
2159 }),
2160 TelemetryEvent::RetryLoopAttempt(RetryLoopAttempt {
2161 common: sample_agent_common(),
2162 attempt: 1,
2163 reason: RetryReason::EmptyContent,
2164 cumulative_latency_ms: 0,
2165 cumulative_cost_usd: 0.0,
2166 cumulative_input_tokens: 0,
2167 cumulative_output_tokens: 0,
2168 }),
2169 TelemetryEvent::TaskAccepted(TaskAccepted {
2170 common: sample_agent_common(),
2171 dispatch_delay_ms: 0,
2172 task_publish_ts: None,
2173 job_age_at_accept_ms: None,
2174 }),
2175 TelemetryEvent::TaskCompleted(TaskCompleted {
2176 common: sample_agent_common(),
2177 duration_ms: 0,
2178 dispatch_delay_ms: 0,
2179 queue_wait_ms: Some(0),
2180 phase_budget_remaining_ms: 0,
2181 llm_attempts: Some(0),
2182 tool_call_count: Some(0),
2183 pending_publish_depth: Some(0),
2184 }),
2185 TelemetryEvent::TaskFailed(TaskFailed {
2186 common: sample_agent_common(),
2187 duration_ms: 0,
2188 dispatch_delay_ms: 0,
2189 queue_wait_ms: Some(0),
2190 phase_budget_remaining_ms: 0,
2191 llm_attempts: Some(0),
2192 tool_call_count: Some(0),
2193 failure_class: TaskFailureClass::Timeout,
2194 pending_publish_depth: Some(0),
2195 }),
2196 TelemetryEvent::NatsConnectionStateChanged(NatsConnectionStateChanged {
2197 common: sample_agent_common(),
2198 state: NatsConnectionState::Connected,
2199 reconnects_so_far: 0,
2200 pending_publish_depth: Some(0),
2201 buffer_bytes: Some(0),
2202 }),
2203 TelemetryEvent::PromptExposureDetected(PromptExposureDetected {
2204 common: sample_agent_common(),
2205 terminal_tool: "submit_proposal".into(),
2206 blocked: true,
2207 hit_count: 0,
2208 response_length_chars: 0,
2209 suspicion_score: 0.0,
2210 xml_tag_hits: 0,
2211 tool_name_hits: 0,
2212 instruction_hits: 0,
2213 wrong_acronym_hits: 0,
2214 sample_hits: vec![],
2215 }),
2216 TelemetryEvent::ApiError(ApiError {
2217 common: sample_agent_common(),
2218 http_status: 404,
2219 error_code: Some("not_found".into()),
2220 endpoint: "/health/{name}".into(),
2221 method: "GET".into(),
2222 duration_ms: 5,
2223 }),
2224 TelemetryEvent::ContextEmergencyShrink(ContextEmergencyShrink {
2225 common: sample_agent_common(),
2226 available_space: 100,
2227 requested_max: 4_000,
2228 floor_used: 200,
2229 estimated_input: 130_000,
2230 context_window: 131_072,
2231 recent_tool_outputs: vec![RecentToolOutput {
2232 tool: "read_file".into(),
2233 bytes: 240_000,
2234 }],
2235 }),
2236 TelemetryEvent::ClaudeSubprocessSpawn(ClaudeSubprocessSpawn {
2237 common: sample_agent_common(),
2238 session_id: "8ce6aa3f-d7c2-0000-0000-000000000000".into(),
2239 lock_present_at_spawn: false,
2240 }),
2241 TelemetryEvent::ClaudeSubprocessExit(ClaudeSubprocessExit {
2242 common: sample_agent_common(),
2243 session_id: "8ce6aa3f-d7c2-0000-0000-000000000000".into(),
2244 exit_code: 0,
2245 wallclock_ms: 12_345,
2246 session_lock_released: true,
2247 }),
2248 TelemetryEvent::ClaudeSessionLockCollision(ClaudeSessionLockCollision {
2249 common: sample_agent_common(),
2250 session_id: "8ce6aa3f-d7c2-0000-0000-000000000000".into(),
2251 prior_lock_age_secs: 42,
2252 prior_pid: Some(31415),
2253 }),
2254 ];
2255 for evt in &samples {
2259 let kind = evt.kind();
2260 assert!(!kind.is_empty());
2261 assert!(kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
2262 let v: serde_json::Value = serde_json::to_value(evt).unwrap();
2263 assert_eq!(
2264 v["type"].as_str(),
2265 Some(kind),
2266 "kind() must match serde tag"
2267 );
2268 }
2269 let mut kinds: Vec<&'static str> = samples.iter().map(|e| e.kind()).collect();
2271 kinds.sort_unstable();
2272 kinds.dedup();
2273 assert_eq!(kinds.len(), samples.len(), "duplicate kind() values");
2274 }
2275
2276 #[test]
2277 fn telemetry_config_defaults_enabled_true() {
2278 let cfg: TelemetryConfig = serde_json::from_str("{}").unwrap();
2279 assert!(cfg.enabled);
2280 assert!(cfg.endpoints.is_empty());
2281 }
2282
2283 #[test]
2284 fn telemetry_config_opt_out() {
2285 let cfg: TelemetryConfig = serde_yaml::from_str("enabled: false\n").unwrap();
2286 assert!(!cfg.enabled);
2287 }
2288
2289 #[test]
2294 fn prompt_exposure_validate_ok() {
2295 let det = PromptExposureDetected {
2296 common: sample_agent_common(),
2297 terminal_tool: "submit_proposal".into(),
2298 blocked: true,
2299 hit_count: 5,
2300 response_length_chars: 1200,
2301 suspicion_score: 3.45,
2302 xml_tag_hits: 2,
2303 tool_name_hits: 1,
2304 instruction_hits: 1,
2305 wrong_acronym_hits: 1,
2306 sample_hits: vec!["xml-tag <working_memory>".into()],
2307 };
2308 assert!(det.validate().is_ok());
2309 }
2310
2311 #[test]
2312 fn prompt_exposure_validate_hit_count_mismatch() {
2313 let det = PromptExposureDetected {
2314 common: sample_agent_common(),
2315 terminal_tool: "submit_proposal".into(),
2316 blocked: false,
2317 hit_count: 99, response_length_chars: 500,
2319 suspicion_score: 2.0,
2320 xml_tag_hits: 1,
2321 tool_name_hits: 1,
2322 instruction_hits: 1,
2323 wrong_acronym_hits: 1,
2324 sample_hits: vec![],
2325 };
2326 let err = det.validate().unwrap_err();
2327 assert!(err.contains("hit_count 99 != sum"));
2328 }
2329
2330 #[test]
2331 fn prompt_exposure_validate_sample_hit_too_long() {
2332 let det = PromptExposureDetected {
2333 common: sample_agent_common(),
2334 terminal_tool: "submit_proposal".into(),
2335 blocked: false,
2336 hit_count: 1,
2337 response_length_chars: 100,
2338 suspicion_score: 1.0,
2339 xml_tag_hits: 1,
2340 tool_name_hits: 0,
2341 instruction_hits: 0,
2342 wrong_acronym_hits: 0,
2343 sample_hits: vec!["a".repeat(65)],
2344 };
2345 let err = det.validate().unwrap_err();
2346 assert!(err.contains("exceeds 64 chars"));
2347 }
2348
2349 #[test]
2350 fn prompt_exposure_validate_sample_hit_unknown_prefix() {
2351 let det = PromptExposureDetected {
2352 common: sample_agent_common(),
2353 terminal_tool: "submit_proposal".into(),
2354 blocked: false,
2355 hit_count: 1,
2356 response_length_chars: 100,
2357 suspicion_score: 1.0,
2358 xml_tag_hits: 1,
2359 tool_name_hits: 0,
2360 instruction_hits: 0,
2361 wrong_acronym_hits: 0,
2362 sample_hits: vec!["the quick brown fox".into()],
2364 };
2365 let err = det.validate().unwrap_err();
2366 assert!(err.contains("does not start with a known dictionary prefix"));
2367 assert!(err.contains("the quick brown fox"));
2368 }
2369
2370 #[test]
2375 fn event_agent_id_accessor() {
2376 let ev = TelemetryEvent::TaskAccepted(TaskAccepted {
2378 common: sample_agent_common(),
2379 dispatch_delay_ms: 42,
2380 task_publish_ts: None,
2381 job_age_at_accept_ms: None,
2382 });
2383 assert_eq!(ev.agent_id(), "CortexB");
2384 }
2385
2386 #[test]
2387 fn emit_drops_mismatched_agent_id() {
2388 let src = TelemetrySource::Agent {
2390 agent_id: "CortexA".into(),
2391 };
2392
2393 let ev_mismatch = TelemetryEvent::TaskAccepted(TaskAccepted {
2395 common: AgentEventCommon {
2396 agent_id: "CortexB".into(),
2397 job_id: Some("job-x".into()),
2398 round: Some(1),
2399 phase: Some(DeliberationPhase::Proposing),
2400 ts: 0,
2401 trace_id: derive_trace_id("job-x", 1, DeliberationPhase::Proposing, "CortexB"),
2402 },
2403 dispatch_delay_ms: 0,
2404 task_publish_ts: None,
2405 job_age_at_accept_ms: None,
2406 });
2407 assert!(
2408 !source_agent_matches(&src, &ev_mismatch),
2409 "CortexB event should not match CortexA source"
2410 );
2411
2412 let ev_match = TelemetryEvent::TaskAccepted(TaskAccepted {
2414 common: AgentEventCommon {
2415 agent_id: "CortexA".into(),
2416 job_id: Some("job-x".into()),
2417 round: Some(1),
2418 phase: Some(DeliberationPhase::Proposing),
2419 ts: 0,
2420 trace_id: derive_trace_id("job-x", 1, DeliberationPhase::Proposing, "CortexA"),
2421 },
2422 dispatch_delay_ms: 0,
2423 task_publish_ts: None,
2424 job_age_at_accept_ms: None,
2425 });
2426 assert!(
2427 source_agent_matches(&src, &ev_match),
2428 "CortexA event should match CortexA source"
2429 );
2430 }
2431
2432 #[test]
2437 fn redact_content_detects_sensitive_words() {
2438 for input in [
2439 "password=abc",
2440 "my_api_key here",
2441 "secret_key: xyz",
2442 "access_key=123",
2443 "credential leaked",
2444 "Bearer tok_abc",
2445 ] {
2446 let out = redact_content(input, 100);
2447 assert_eq!(out, "REDACTED_SENSITIVE", "expected redaction for: {input}");
2448 }
2449 }
2450
2451 #[test]
2452 fn redact_content_no_false_positives() {
2453 for input in [
2456 "keyword research",
2457 "the author of this",
2458 "a monkey in the tree",
2459 "public_authority report",
2460 "the secret of his success",
2461 "authenticate the user",
2462 "tokenized assets",
2463 "keyboard warrior",
2464 ] {
2465 let out = redact_content(input, 100);
2466 assert_eq!(out, input, "expected no redaction for: {input}");
2467 }
2468 }
2469
2470 #[test]
2471 fn redact_content_truncates_long_input() {
2472 let input = "a".repeat(200);
2473 let out = redact_content(&input, 50);
2474 assert!(out.ends_with("..."));
2475 assert_eq!(out.chars().count(), 53); }
2477
2478 #[test]
2479 fn redact_content_empty_input() {
2480 assert_eq!(redact_content("", 100), "<empty>");
2481 }
2482
2483 #[test]
2484 fn redact_url_strips_credentials_in_authority() {
2485 assert_eq!(
2486 redact_url("https://user:pass@api.example.com/path"),
2487 "https://api.example.com/path"
2488 );
2489 assert_eq!(
2490 redact_url("http://admin:secret@host.com"),
2491 "http://host.com"
2492 );
2493 }
2494
2495 #[test]
2496 fn redact_url_preserves_at_in_path() {
2497 assert_eq!(
2498 redact_url("https://api.example.com/users/foo@bar/profile"),
2499 "https://api.example.com/users/foo@bar/profile"
2500 );
2501 }
2502
2503 #[test]
2504 fn redact_url_removes_query_and_fragment() {
2505 assert_eq!(
2506 redact_url("https://example.com/path?query=1#frag"),
2507 "https://example.com/path"
2508 );
2509 }
2510
2511 #[test]
2512 fn redact_url_no_credentials() {
2513 assert_eq!(
2514 redact_url("https://example.com/path"),
2515 "https://example.com/path"
2516 );
2517 }
2518
2519 #[test]
2520 fn redact_error_message_delegates_to_redact_content() {
2521 let msg = "password leaked in log";
2522 assert_eq!(redact_error_message(msg), "REDACTED_SENSITIVE");
2523 let long = "a".repeat(200);
2524 let out = redact_error_message(&long);
2525 assert!(out.ends_with("..."));
2526 }
2527}