Skip to main content

quorum_rs/
telemetry.rs

1//! Operational telemetry — independent log streams for orchestrator and
2//! agent processes.
3//!
4//! This module ships the event catalog (as a Rust type), subject
5//! derivation, trace correlation, and a fire-and-forget emission
6//! helper.
7//!
8//! # Design principles (operator-facing contract lives in
9//! `docs/agent-telemetry.md`)
10//!
11//! 1. **Metrics only, no content.** Every variant in [`TelemetryEvent`]
12//!    carries IDs, durations, counts, enums, and boolean flags —
13//!    never prompt text, proposal bodies, evaluation justifications,
14//!    raw LLM outputs, thought process, or secret material. Redaction
15//!    is enforced at the *type* layer: fields that could carry text
16//!    simply do not exist on these structs.
17//! 2. **Role-scoped subjects.** Orchestrator publishes under
18//!    `telemetry.orch.*`; each agent publishes under
19//!    `telemetry.agent.{agent_id}.*`. The agent_id position is
20//!    JWT-bound so one agent cannot forge another agent's subtree.
21//! 3. **Fire-and-forget, zero-cost when disabled.** [`emit`] returns
22//!    immediately on success, silently drops on serialization or
23//!    publish failure. Telemetry must never gate critical-path work.
24//!    When [`TelemetryConfig::enabled`] is `false`, no events are
25//!    constructed.
26//! 4. **Trace correlation without coordination.** Orchestrator and
27//!    agent independently derive the same [`trace_id`] from
28//!    `(job_id, round, phase, agent_id)` via the shared
29//!    [`derive_trace_id`] function. No protocol message carries the
30//!    trace id — it is a pure function of public identifiers.
31//!
32//! Consumers that do not construct a [`TelemetryEmitter`] pay zero
33//! runtime cost.
34
35use serde::{Deserialize, Serialize};
36use sha2::{Digest, Sha256};
37
38use crate::agents::DeliberationPhase;
39
40/// Default subject prefix for the per-agent telemetry tree.
41/// The `{agent_id}` position is JWT-bound so agents cannot cross tenants.
42pub const TELEMETRY_AGENT_PREFIX: &str = "telemetry.agent";
43
44/// Length (in hex chars) of the correlation id derived by
45/// [`derive_trace_id`]. 32 hex chars = 128 bits of identifier space —
46/// enough to keep birthday-collision probability negligible at
47/// telemetry scale (one event per LLM attempt, per tool call, per
48/// retry, across multiple agents and long-lived jobs). Do not narrow
49/// below 32 without reviewing the collision math; the unit test
50/// `trace_id_width_is_at_least_128_bits` is a regression guard.
51pub const TRACE_ID_LEN: usize = 32;
52
53/// Configuration for telemetry emission.
54///
55/// Defaults to **enabled**; an operator who wants to opt their agent
56/// out of telemetry sets `telemetry.enabled: false` in the agent YAML.
57/// The orchestrator block mirrors the same shape for symmetry.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct TelemetryConfig {
60    /// Master switch. When `false`, no events are emitted and no
61    /// emitter is constructed. Defaults to `true`.
62    #[serde(default = "default_enabled")]
63    pub enabled: bool,
64    /// Telemetry destinations. One entry per destination — typically
65    /// the service operator's NATS plus the agent operator's own
66    /// dashboard NATS for OSS-split deployments.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub endpoints: Vec<TelemetryEndpointConfig>,
69}
70
71/// One telemetry destination.
72///
73/// Each endpoint owns its own NATS connection, credentials, and
74/// subject prefix. The SDK's [`TelemetryEmitterMux`] fans events out
75/// to every configured endpoint; targeted emission for one
76/// destination uses [`TelemetryEmitterMux::emit_for`].
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct TelemetryEndpointConfig {
79    /// Operator-readable name used by [`TelemetryEmitterMux::emit_for`]
80    /// and shown in `dropped_count` per-endpoint reports. Must be
81    /// unique within the `endpoints` list.
82    pub name: String,
83    /// NATS server URL for this endpoint. `None` means "reuse the
84    /// agent's primary NATS connection" — the legacy single-endpoint
85    /// path.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub nats_url: Option<String>,
88    /// Path to a NATS `.creds` file authorising this agent to publish
89    /// to its telemetry subtree on the endpoint's NATS account. `None`
90    /// when reusing the primary connection (which already has
91    /// credentials from the orchestrator-issued JWT).
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub creds: Option<String>,
94    /// Subject prefix override for this endpoint. `None` falls back
95    /// to [`TELEMETRY_AGENT_PREFIX`].
96    #[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/// Which process is emitting an event.
114///
115/// Determines the NATS subject prefix. Currently only supports agent
116/// sources; the orchestrator defines its own source type in its crate.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum TelemetrySource {
119    /// Agent-side event. Subject: `telemetry.agent.<agent_id>.<event>`.
120    Agent {
121        /// Agent id as it appears in the JWT subject bind.
122        agent_id: String,
123    },
124}
125
126impl TelemetrySource {
127    /// Construct an `Agent` source after validating that `agent_id`
128    /// is a NATS-safe subject token. Returns `Err` if the id contains
129    /// `.` / `*` / `>` / whitespace / control characters or is empty
130    /// — using such an id would otherwise reshape the subject
131    /// hierarchy and silently break the JWT-bound `agent_id`
132    /// position contract.
133    ///
134    /// Direct struct-literal construction (`TelemetrySource::Agent { … }`)
135    /// is still permitted for tests and for callers that have
136    /// already validated their input upstream (e.g. orchestrator
137    /// JWT-issuance flow), but [`subject`](Self::subject) re-validates
138    /// as defence-in-depth and refuses to emit malformed subjects.
139    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    /// Compute the NATS subject for a given event kind, optionally using a
146    /// custom prefix (for testing / tenanted deployments).
147    ///
148    /// The trailing path segment is the event's snake_case kind (e.g.
149    /// `"task_accepted"`). No event-payload fields appear in the subject,
150    /// so there is no injection surface from the event data. The `agent_id`
151    /// and any `custom_prefix` are validated against
152    /// [`crate::nats_utils::validate_nats_name`]; an invalid token
153    /// produces `Err` and the caller (typically [`TelemetryEmitter::emit`])
154    /// must drop the event rather than ship a malformed subject.
155    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
171/// Deterministic 32-char (128-bit) hex correlation id.
172///
173/// Orchestrator and agent compute the same id from the same
174/// `(job_id, round, phase, agent_id)` tuple. A forwarder / sink can
175/// stitch per-process spans into a single trace without any runtime
176/// coordination protocol.
177///
178/// Input encoding is **length-prefixed** for each variable-length
179/// component (`{len}:{bytes}`) so two distinct input tuples can never
180/// collide via boundary ambiguity, regardless of which characters
181/// (including `':'`) the identifiers contain. Length is encoded in
182/// decimal ASCII — the value is the byte length of the UTF-8 form.
183/// Fixed-width fields (`round` u32, `phase` discriminant) are
184/// appended without prefixes since their boundaries are
185/// unambiguous from the schema.
186pub 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
208/// Convenience wrapper around [`derive_trace_id`] with consistent naming
209/// for telemetry callers. All agent-side telemetry uses this function so
210/// the correlation-id derivation is uniform across modules.
211pub 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
215/// `trace_id` for events that have no task scope (e.g. the worker's
216/// NATS connection-state monitor). Same `TRACE_ID_LEN` lowercase-hex
217/// shape `derive_trace_id` produces, so consumers can parse `trace_id`
218/// uniformly across the catalog. Derives the digest from a
219/// length-prefixed `(agent_id, uuid_v4)` input — UUIDv4 supplies
220/// 122 bits of entropy, length-prefixing the agent_id matches the
221/// boundary-disambiguation property `derive_trace_id` already uses.
222fn 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// ---------------------------------------------------------------------------
236// Common envelope
237// ---------------------------------------------------------------------------
238
239/// Fields that every agent-side event carries.
240///
241/// Orchestrator events reuse a subset (no `agent_id`, no `phase` for
242/// cross-round events) — structured here as a helper rather than a
243/// forced mixin so each variant keeps its own flat JSON shape.
244///
245/// `job_id`, `round`, and `phase` are `Option` so that session-less
246/// events (e.g. [`NatsConnectionStateChanged`]) can omit them cleanly
247/// rather than fabricating placeholder values.
248#[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    /// Unix milliseconds at emission time.
258    pub ts: i64,
259    /// 32-char (128-bit) hex produced by [`derive_trace_id`].
260    pub trace_id: String,
261}
262
263// ---------------------------------------------------------------------------
264// TelemetryContext + emit_event! macro
265// ---------------------------------------------------------------------------
266
267/// Lightweight context for telemetry emission.
268///
269/// Carries the identifiers common to all agent-side events and
270/// produces an [`AgentEventCommon`] via [`Self::common`].
271/// `job_id`, `round`, and `phase` are `Option` so that session-less
272/// callers (e.g. NATS connection state events) can leave them unset.
273#[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    /// Construct a new context.
284    ///
285    /// `job_id`, `round`, and `phase` may be `None` for events that
286    /// genuinely have no task scope — the worker's NATS connection-
287    /// state monitor is the canonical example. In that case
288    /// `trace_id` is a per-event UUID (`nosess-{agent}-{uuid}`)
289    /// rather than a `derive_trace_id(...)` derivation, because
290    /// deriving from a constant tuple (e.g. `("nosess", 0, Proposing)`)
291    /// would alias every session-less event for the same agent under
292    /// a single trace, defeating correlation.
293    ///
294    /// For task-scoped emission, callers go through
295    /// [`AgentContext::telemetry_for`](crate::agents::AgentContext::telemetry_for),
296    /// which enforces the `session_id` invariant and never reaches
297    /// the no-session branch here.
298    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    /// Produce the shared envelope fields for a telemetry event.
318    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/// Emit a telemetry event when an emitter is available.
331///
332/// Reduces boilerplate at emit sites from ~12 lines to 1:
333/// ```ignore
334/// emit_event!(Some(&emitter), ctx, LlmRequestStart {
335///     request_id: "r1".into(),
336///     model: "gpt-4".into(),
337///     provider_id: "openai".into(),
338///     attempt: 1,
339///     estimated_input_tokens: 100,
340/// });
341/// ```
342#[macro_export]
343macro_rules! emit_event {
344    // Low-level: caller has `Option<&TelemetryEmitter>` and a
345    // `TelemetryContext` directly. Used in process-level paths
346    // without an `AgentContext` (e.g. the worker's NATS
347    // connection-state monitor).
348    ($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/// Emit a telemetry event scoped to the task on the given context.
360///
361/// Pulls the emitter from `context.telemetry` and derives the
362/// `TelemetryContext` envelope via `context.telemetry_for()` —
363/// `agent_id` is taken from `context.agent_id` (populated by the
364/// orchestrator at dispatch and by the worker after deserialize).
365/// One argument-list, no double-`context.` plumbing at the call
366/// site:
367///
368/// ```ignore
369/// emit_for!(context, ToolCallExecuted {
370///     tool_name: name,
371///     latency_ms: 42,
372///     success: true,
373/// });
374/// ```
375///
376/// Use [`emit_event!`] for the lower-level form when no
377/// [`AgentContext`](crate::agents::AgentContext) is in scope (e.g.
378/// process-level connection-state events).
379#[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// ---------------------------------------------------------------------------
394// Failure / error taxonomy
395// ---------------------------------------------------------------------------
396
397/// Error classification emitted on [`LlmRequestFailed`].
398#[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
410// `LlmError` (the typed error from `AiModel::chat_completion`) lives
411// in `crate::llms::error` because it's an AiModel concern, not a
412// telemetry concept. Re-exported here for ergonomic backward
413// compatibility — existing call sites that imported via
414// `crate::telemetry::LlmError` keep compiling.
415pub use crate::llms::LlmError;
416
417/// Terminal finish reason reported by the provider.
418#[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/// Reason a structured-output retry was triggered.
428#[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/// Terminal failure class on [`TaskFailed`].
438#[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/// State transition on the agent's NATS client (G6).
450#[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// ---------------------------------------------------------------------------
470// Agent events
471// ---------------------------------------------------------------------------
472
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
474pub struct LlmRequestStart {
475    #[serde(flatten)]
476    pub common: AgentEventCommon,
477    /// Opaque id used to correlate with [`LlmRequestComplete`] /
478    /// [`LlmRequestFailed`] / [`LlmRequestStalled`] events from the
479    /// same in-flight call.
480    pub request_id: String,
481    pub model: String,
482    pub provider_id: String,
483    pub attempt: u32,
484    pub estimated_input_tokens: u32,
485    /// `100 * estimated_input_tokens / context_window`, clamped to
486    /// `[0, 100]`. `0.0` when `context_window` is unknown.
487    #[serde(default)]
488    pub context_utilization_pct: f64,
489    /// Sum of [`ToolCallExecuted::output_bytes`] for this task so far.
490    #[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    /// G1 — time-to-first-token when the provider streams. `None`
501    /// when the provider response is non-streaming.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub ttft_ms: Option<u64>,
504    /// G1 — ms from first token to finish. `None` on non-streaming.
505    #[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    /// G7 — evaluate-phase only: structured-output array lengths.
519    /// `None` on propose phase.
520    #[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    /// Char count of the serialized input messages JSON. Tokenizer-
525    /// agnostic request-size proxy that survives provider differences;
526    /// pairs with `input_tokens` to surface tokenization anomalies.
527    #[serde(default)]
528    pub messages_chars: u32,
529    /// `max_tokens` value the agent asked the provider for at this
530    /// request. Useful when reactive context-shrink kicks in: lets
531    /// dashboards correlate `finish_reason == Length` with the
532    /// shrink-retry path. `None` when the provider strategy doesn't
533    /// surface a max-tokens cap.
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub max_tokens_requested: Option<u32>,
536    /// Char count of the generated content (the `message.content`
537    /// across choices, post-strip-reasoning). Pairs with
538    /// `output_tokens` to spot tokenization anomalies.
539    #[serde(default)]
540    pub response_chars: u32,
541    /// Number of tool calls the model emitted on this request.
542    /// Distinct from [`ToolCallExecuted`] (per-call telemetry) and
543    /// [`TaskCompleted::tool_call_count`] (cumulative across the
544    /// whole task). Captures the per-turn fan-out.
545    #[serde(default)]
546    pub tool_calls_emitted: u32,
547    /// `true` only when `available < floor` — distinct from healthy
548    /// `available > floor` adaptive shrinks.
549    #[serde(default)]
550    pub max_tokens_shrunk_to_floor: bool,
551    /// Raw headroom at dispatch (`context_window - estimated_input`,
552    /// saturating non-negative). `None` when `context_window` is
553    /// unknown.
554    #[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/// 30s-cadence heartbeat for in-flight LLM requests. Fired by the
575/// agent on a timer while an [`LlmRequestStart`] has no matching
576/// terminal event; cancelled automatically when the request
577/// completes or fails.
578#[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    /// Length of the tool result payload in bytes.
597    #[serde(default)]
598    pub output_bytes: u64,
599    /// `ceil(output_bytes / 4)` from the built-in agent. `None` is
600    /// reserved for callers without a tokenizer hint.
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub output_tokens_estimated: Option<u32>,
603    /// `true` when the tool's `max_bytes` cap clipped the result.
604    #[serde(default)]
605    pub truncated: bool,
606    /// `true` when the tool emitted a `next_offset` cursor.
607    #[serde(default)]
608    pub paginated: bool,
609}
610
611/// Emitted once per `propose`/`evaluate` call, recording what prior-round
612/// context the agent assembled into its prompt and whether it wrote its
613/// scratchpad. Lets dashboards confirm a *serving* agent actually inspects its
614/// own past proposals/evals + scratchpad during normal operation — the same
615/// signals `quorum smoke-test` prints in-process.
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
617pub struct DeliberationContextAssembled {
618    #[serde(flatten)]
619    pub common: AgentEventCommon,
620    /// Scratchpad characters loaded into the prompt from the persistent store.
621    #[serde(default)]
622    pub scratchpad_loaded_chars: u32,
623    /// `true` when the agent wrote its scratchpad during this call.
624    #[serde(default)]
625    pub scratchpad_written: bool,
626    #[serde(default)]
627    pub scratchpad_written_chars: u32,
628    /// `true` when the prior round's own proposal was fed back into the prompt.
629    #[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    /// Candidates fed to the evaluate phase (`0` in propose).
636    #[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    /// G4 — rollup cost across this task's attempts so far.
650    #[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    /// Unix ms the orchestrator stamped on the envelope at publish.
664    /// `None` on payloads from a publisher that didn't stamp.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub task_publish_ts: Option<i64>,
667    /// `agent_receive_ts - task_publish_ts`, clamped non-negative.
668    /// `None` when `task_publish_ts` is missing.
669    #[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    /// `Some(n)` once first-`llm_request_start` timing is recorded;
680    /// `None` until the per-task counter wiring lands. Distinguishes
681    /// "task ran but we didn't measure" from "task ran with zero
682    /// queue wait".
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub queue_wait_ms: Option<u64>,
685    pub phase_budget_remaining_ms: i64,
686    /// `Some(n)` once each `NsedAgent` impl exposes its retry
687    /// counter; `None` until that wiring lands.
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub llm_attempts: Option<u32>,
690    /// `Some(n)` once `AgentResponse.tool_usage` is summed at task
691    /// boundary; `None` until that wiring lands.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub tool_call_count: Option<u32>,
694    /// G6 — NATS client local buffer depth at submit time. `> 0`
695    /// signals a forwarder / connection issue holding the agent's
696    /// submission on-process. `None` until `async_nats::Client`
697    /// introspection is wired.
698    #[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/// G6 — NATS client transition on the agent side.
721#[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    /// `None` until `async_nats::Client` introspection is wired.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub pending_publish_depth: Option<u32>,
730    /// `None` until `async_nats::Client` introspection is wired.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub buffer_bytes: Option<u64>,
733}
734
735/// Prompt-exposure guardrail fired on an agent's terminal-tool content.
736///
737/// Emitted by any [`OutputLeakDetector`](crate::agents::OutputLeakDetector)
738/// implementation: it reports that the guardrail saw one or more dictionary
739/// hits on the agent's final response content, whether that tripped the retry
740/// threshold, and how many hits landed in each category.
741///
742/// **Redaction posture.** The only text this event carries is
743/// `sample_hits`, which is drawn from the guardrail's *fixed
744/// dictionaries* (`xml-tag <tag_name>`, `tool-name <name>`,
745/// `instruction "phrase"`, `wrong-acronym "acronym"`) — public
746/// identifiers that already appear in the source tree. No portion of
747/// the scanned response content is emitted. `sample_hits` is capped
748/// by the guardrail at the same length as the retry-reason string so
749/// the two surfaces cannot diverge.
750///
751/// **Threshold semantics.** `blocked = true` means the guardrail
752/// rejected the response and forced a retry; `blocked = false` means
753/// hits were observed but fell under the `min_suspicion_score` /
754/// `min_matches` / `min_answer_length_chars` gates so the response
755/// was allowed through. Dashboards use the `(blocked, hit_count)`
756/// tuple to compute false-positive rates and tune thresholds.
757#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
758pub struct PromptExposureDetected {
759    #[serde(flatten)]
760    pub common: AgentEventCommon,
761    /// The terminal tool whose content was scanned
762    /// (`submit_proposal`, `submit_batch_evaluation`, or any other
763    /// terminal tool the agent was using for this task).
764    pub terminal_tool: String,
765    /// `true` when the guardrail rejected the response and triggered a
766    /// retry; `false` when hits were observed but fell under threshold.
767    pub blocked: bool,
768    /// Total hits across all categories. Invariant:
769    /// `hit_count == xml_tag_hits + tool_name_hits + instruction_hits
770    /// + wrong_acronym_hits`.
771    pub hit_count: u32,
772    /// Length of the scanned content in chars. Feeds the suspicion
773    /// score computation on the detector side.
774    pub response_length_chars: u32,
775    /// Log-scaled suspicion score the guardrail computed for this
776    /// response (`hit_count * log2(1 + len / unit_chars)`), so
777    /// operators can reproduce the guardrail's threshold logic from
778    /// telemetry alone.
779    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    /// Sample of the first few dictionary-sourced hit labels in the
785    /// same format the guardrail uses internally
786    /// (`xml-tag <working_memory>`, `tool-name submit_proposal`,
787    /// `instruction "Proposing Phase"`, `wrong-acronym "Neural
788    /// Swarm"`). Capped so the payload stays bounded; never contains
789    /// scanned-content fragments.
790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
791    pub sample_hits: Vec<String>,
792}
793
794impl PromptExposureDetected {
795    /// Known dictionary prefixes for sample_hits labels. Each entry is a
796    /// short, fixed dictionary identifier used by the guardrail — no free-
797    /// form user content.
798    const ALLOWED_PREFIXES: &'static [&'static str] =
799        &["xml-tag ", "tool-name ", "instruction ", "wrong-acronym "];
800
801    /// Validate structural invariants before serialization.
802    ///
803    /// 1. `hit_count` must equal the sum of all category counters.
804    /// 2. `sample_hits` must not contain raw scanned-content fragments
805    ///    (they should only be dictionary-sourced labels like
806    ///    `"xml-tag <working_memory>"`).
807    ///
808    /// Returns a descriptive error when any invariant is violated.
809    /// Callers should drop the event and increment a drop counter.
810    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        // Reject sample_hits that look like raw content. Labels are
829        // short token-like strings starting with a known dictionary
830        // prefix; anything > 64 chars is suspicious.
831        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            // Each entry must start with a known dictionary prefix.
840            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// ---------------------------------------------------------------------------
851// Context-emergency shrink
852// ---------------------------------------------------------------------------
853
854/// One contributor to the running tool-output total carried by
855/// [`ContextEmergencyShrink::recent_tool_outputs`]. Only the top-N
856/// (default 5) are emitted so the payload stays bounded.
857#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
858pub struct RecentToolOutput {
859    pub tool: String,
860    pub bytes: u64,
861}
862
863/// Fires once per task per shrink-to-floor with the bloat
864/// attribution payload.
865#[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    /// The floor the SDK clamped to (typically 200).
872    pub floor_used: u32,
873    pub estimated_input: u32,
874    pub context_window: u32,
875    /// Top contributors to bloat in this task (size-bounded; limit
876    /// 5). `tool` (public name) + `bytes` only — content is never
877    /// disclosed.
878    #[serde(default)]
879    pub recent_tool_outputs: Vec<RecentToolOutput>,
880}
881
882// ---------------------------------------------------------------------------
883// claude_cli subprocess lifecycle
884// ---------------------------------------------------------------------------
885
886/// Fires for `provider_type: claude_cli` only.
887#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
888pub struct ClaudeSubprocessSpawn {
889    #[serde(flatten)]
890    pub common: AgentEventCommon,
891    /// Stable UUID under `~/.claude/projects/-work/<sid>/`.
892    pub session_id: String,
893    /// Previous run leaked the lock; this spawn will collide.
894    pub lock_present_at_spawn: bool,
895}
896
897/// Pairs with [`ClaudeSubprocessSpawn`] via `session_id`.
898#[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    /// `false` = leaked lock; next spawn collides.
906    pub session_lock_released: bool,
907}
908
909/// Fired by spawn when it discovers a prior lock file.
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
911pub struct ClaudeSessionLockCollision {
912    #[serde(flatten)]
913    pub common: AgentEventCommon,
914    pub session_id: String,
915    /// mtime delta on the lock file.
916    pub prior_lock_age_secs: u64,
917    /// `None` when claude doesn't write the PID into the lock.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub prior_pid: Option<i32>,
920}
921
922// ---------------------------------------------------------------------------
923// Event union
924// ---------------------------------------------------------------------------
925
926/// All telemetry event variants. The serde `type` tag makes the event
927/// identifiable without subject parsing (the forwarder re-encodes
928/// into its sink's schema).
929///
930/// HTTP error emitted by axum middleware on every 4xx/5xx response
931/// from the agent's status server. Mirrors the orchestrator-side
932/// `OrchestratorTelemetryEvent::ApiError` so dashboards can join on
933/// `agent_id` (agent only) or `operator_principal` (orch only) to
934/// attribute the failure.
935///
936/// `operator_principal` is intentionally absent — agent-side requests
937/// are not authenticated as operators. The orch
938/// catalog carries the principal field on its own `ApiError` variant.
939#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
940pub struct ApiError {
941    #[serde(flatten)]
942    pub common: AgentEventCommon,
943    /// HTTP status code returned to the client.
944    pub http_status: u16,
945    /// Stable error code emitted by the handler when one is available
946    /// (e.g. `"job_not_found"`); `None` for raw 4xx/5xx with no
947    /// programmer-supplied code.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub error_code: Option<String>,
950    /// Path template the request matched (e.g. `"/api/operators/{name}"`).
951    /// Falls back to the raw path when the framework can't supply a
952    /// route template.
953    pub endpoint: String,
954    pub method: String,
955    pub duration_ms: u64,
956}
957
958/// Adding a variant: also update [`TelemetryEvent::kind`] so the
959/// subject-derivation table stays in sync. The unit test
960/// `event_kind_covers_every_variant` guards this invariant.
961#[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    /// snake_case discriminant used as the final subject segment.
986    ///
987    /// Matches the serde `type` tag value 1:1.
988    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    /// Returns the `agent_id` carried by this event.
1011    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
1034// ---------------------------------------------------------------------------
1035// Emitter
1036// ---------------------------------------------------------------------------
1037
1038/// Returns `true` when the event's `agent_id` matches the source's
1039/// identity. For `TelemetrySource::Agent` this is a direct string
1040/// comparison. Since `TelemetrySource` currently has only the `Agent`
1041/// variant, this always returns `true` — but the function exists so
1042/// adding `Orchestrator` or sub-tenant variants in the future forces
1043/// an exhaustiveness update here.
1044fn 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/// Fire-and-forget telemetry publisher.
1051///
1052/// Zero-await on the hot path: the NATS client's internal buffer
1053/// absorbs transient spikes. Serialization or publish failure is
1054/// **silently dropped** — telemetry must never gate critical-path
1055/// work. All failures are counted via atomics so tests + dashboards
1056/// can still observe drop rates.
1057///
1058/// Cloning is cheap: the inner [`async_nats::Client`] is already
1059/// `Clone` and the counter is an `Arc`.
1060///
1061/// `Debug` skips the NATS client (its impl is verbose and not useful
1062/// for our purposes); only the source identity and drop counter are
1063/// printed so an `AgentContext` Debug-format is concise.
1064#[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    /// Override the subject prefix (forwarder tenanted deployments).
1093    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
1094        self.custom_prefix = Some(prefix.into());
1095        self
1096    }
1097
1098    /// Number of events dropped due to serialization or publish
1099    /// failure since this emitter was constructed. Exposed for
1100    /// tests and operator visibility.
1101    pub fn dropped_count(&self) -> u64 {
1102        self.dropped.load(std::sync::atomic::Ordering::Relaxed)
1103    }
1104
1105    /// Publish an event. Returns immediately; publish happens inside
1106    /// the `async_nats` client's outbound queue. The event is
1107    /// **silently dropped** (counter increments) on any of:
1108    ///
1109    /// - Subject derivation failure (invalid `agent_id` or
1110    ///   `custom_prefix`).
1111    /// - Serialization failure (programmer error — should not happen
1112    ///   for well-typed `TelemetryEvent` variants).
1113    /// - No active Tokio runtime — calling `emit()` from a thread
1114    ///   that has no current runtime increments `dropped` instead of
1115    ///   panicking. Telemetry must never crash the caller.
1116    /// - The async-nats client's `publish()` future returning `Err`.
1117    pub fn emit(&self, event: &TelemetryEvent) {
1118        // When emitting as an agent, the event's payload agent_id
1119        // must match the emitter's source identity.
1120        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        // Validate PromptExposureDetected structural invariants before
1136        // serialization. Invalid events are dropped with a counted drop.
1137        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        // `try_publish` when available in the NATS client would be
1169        // strictly preferable (no await at all); `publish` returns a
1170        // future we intentionally fire-and-forget. Spawning onto the
1171        // current runtime is acceptable because the queue is bounded
1172        // and backpressure is absorbed in the NATS client's internal
1173        // buffer, not propagated to the caller.
1174        //
1175        // `tokio::spawn` panics outside an active runtime; guard with
1176        // `try_current()` so `emit()` from a non-Tokio thread degrades
1177        // to a counted drop rather than crashing the caller.
1178        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/// Fan-out wrapper over multiple [`TelemetryEmitter`]s — one per
1197/// configured destination. Lets a single agent publish telemetry to
1198/// the service operator's NATS *and* the agent operator's own NATS
1199/// in parallel, without coupling the SDK to a single shared bus.
1200///
1201/// `emit()` fans an event to every endpoint (default operational
1202/// shape — same event, every dashboard); `emit_for(name, &event)`
1203/// targets one endpoint by name for cases where an event genuinely
1204/// belongs to one destination only.
1205///
1206/// Each endpoint independently absorbs publish failures via its
1207/// own `TelemetryEmitter::dropped_count`; the mux's
1208/// [`dropped_count`](Self::dropped_count) sums across them.
1209///
1210/// Single-endpoint legacy deployments construct a one-element mux
1211/// and call `emit()` — same wire shape as the prior single-emitter
1212/// path.
1213#[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/// Construction-time errors for [`TelemetryEmitterMux`].
1229#[derive(Debug, Clone, PartialEq, Eq)]
1230pub enum TelemetryMuxError {
1231    /// Two or more endpoints share a name. `emit_for(name)` would
1232    /// silently target only the first match — fail at construction
1233    /// instead so misconfigured registries surface immediately.
1234    /// The payload is the list of names that appeared more than
1235    /// once (deduplicated, in first-collision order).
1236    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
1252/// Validate endpoint-name uniqueness without touching any emitters.
1253/// Extracted as a free function so unit tests can exercise the
1254/// duplicate-detection contract synchronously without a NATS client.
1255fn 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    /// Construct a mux from `(name, emitter)` pairs. Returns
1272    /// [`TelemetryMuxError::DuplicateNames`] if any name appears more
1273    /// than once — `emit_for(name)` would otherwise silently resolve
1274    /// to the first match and drop fan-outs to the rest, which is a
1275    /// hard-to-debug misconfiguration.
1276    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    /// Wrap a single emitter as a one-element mux. Convenience for
1283    /// callers that built one emitter and need the mux-shaped
1284    /// interface — name uniqueness is trivially satisfied so this
1285    /// stays infallible.
1286    pub fn single(name: impl Into<String>, emitter: TelemetryEmitter) -> Self {
1287        Self {
1288            endpoints: vec![(name.into(), emitter)],
1289        }
1290    }
1291
1292    /// Number of configured endpoints.
1293    pub fn len(&self) -> usize {
1294        self.endpoints.len()
1295    }
1296
1297    /// `true` when no endpoints are configured. The mux still
1298    /// implements [`emit`](Self::emit) (no-op) so callers don't need
1299    /// an `Option<TelemetryEmitterMux>` wrapper at every site.
1300    pub fn is_empty(&self) -> bool {
1301        self.endpoints.is_empty()
1302    }
1303
1304    /// Endpoint names in declaration order. For dashboards that want
1305    /// to enumerate destinations.
1306    pub fn endpoint_names(&self) -> Vec<&str> {
1307        self.endpoints.iter().map(|(n, _)| n.as_str()).collect()
1308    }
1309
1310    /// Sum of dropped counts across endpoints.
1311    pub fn dropped_count(&self) -> u64 {
1312        self.endpoints.iter().map(|(_, e)| e.dropped_count()).sum()
1313    }
1314
1315    /// Fire-and-forget fan-out: emit `event` to every configured
1316    /// endpoint. No-op when the mux is empty.
1317    pub fn emit(&self, event: &TelemetryEvent) {
1318        for (_, emitter) in &self.endpoints {
1319            emitter.emit(event);
1320        }
1321    }
1322
1323    /// Fire-and-forget targeted emission. Drops with no counter
1324    /// increment when the named endpoint isn't configured —
1325    /// `emit_for` is a hint, not a contract; the schema can't tell
1326    /// at compile time which endpoints an operator deployed. The
1327    /// miss path logs at `debug` level with the requested name and
1328    /// the configured endpoint names so operators can spot
1329    /// targeting typos without paying for an error path.
1330    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/// Errors from [`connect_endpoints`].
1344#[derive(Debug, thiserror::Error)]
1345pub enum TelemetryConnectError {
1346    /// `agent_id` failed [`TelemetrySource::agent`] validation.
1347    #[error("invalid agent_id: {0}")]
1348    InvalidAgentId(String),
1349    /// Endpoint config has `nats_url: None`. The schema field is
1350    /// optional but every endpoint must declare a URL — `None` would
1351    /// otherwise mean "reuse some other connection" which we don't
1352    /// model.
1353    #[error("telemetry endpoint `{name}` missing nats_url")]
1354    MissingNatsUrl {
1355        /// Configured endpoint name.
1356        name: String,
1357    },
1358    /// NATS connect failed for the endpoint.
1359    #[error("connect to NATS for telemetry endpoint `{name}` failed: {source}")]
1360    NatsConnect {
1361        /// Configured endpoint name.
1362        name: String,
1363        /// Underlying connect error from [`connect_nats`].
1364        #[source]
1365        source: anyhow::Error,
1366    },
1367    /// Mux construction rejected the endpoint list (duplicate names).
1368    #[error("mux construction: {0}")]
1369    Mux(#[from] TelemetryMuxError),
1370}
1371
1372/// Build a [`TelemetryEmitterMux`] from a [`TelemetryConfig`].
1373///
1374/// Returns `Ok(None)` when telemetry is disabled or `endpoints` is
1375/// empty — the worker treats that as "no emission" and skips the
1376/// emit sites entirely. Returns `Ok(Some(mux))` with one connected
1377/// emitter per endpoint when both are populated.
1378///
1379/// Each endpoint gets its own NATS client + credentials. Endpoints
1380/// that point at the same URL still get independent connections —
1381/// `async-nats` doesn't pool by address, and the simplification of
1382/// keeping each endpoint self-contained at this layer outweighs the
1383/// extra socket. Operators who want connection sharing collapse the
1384/// duplicate endpoints into one entry in the YAML.
1385pub 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
1422// ---------------------------------------------------------------------------
1423// Privacy redaction helpers
1424// ---------------------------------------------------------------------------
1425
1426/// Redact sensitive content for telemetry.
1427///
1428/// Returns a redacted version of the input string suitable for telemetry.
1429/// For privacy reasons, we never emit full error messages, prompts, or content.
1430///
1431/// # Arguments
1432///
1433/// * `input` - The input string to redact
1434/// * `max_length` - Maximum length to keep
1435///
1436/// # Returns
1437///
1438/// A redacted string, or `"REDACTED_SENSITIVE"` if the input contains
1439/// sensitive patterns.
1440pub fn redact_content(input: &str, max_length: usize) -> String {
1441    if input.is_empty() {
1442        return "<empty>".to_string();
1443    }
1444
1445    // Check for obviously sensitive patterns using word-boundary
1446    // matching to avoid false positives on "keyword", "author",
1447    // "monkey", "public_authority", etc.
1448    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        // Simple word-boundary: look for the word preceded/followed by
1459        // a non-alphanumeric character (or start/end of string).
1460        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    // Truncate to max_length chars (not bytes) to avoid panicking on multi-byte UTF-8
1474    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
1486/// Redact error message for telemetry (max 100 chars).
1487pub fn redact_error_message(error_msg: &str) -> String {
1488    redact_content(error_msg, 100)
1489}
1490
1491/// Redact URL for telemetry — strips query params, fragments, and
1492/// embedded credentials (`user:pass@host`) from the authority section
1493/// only. Paths containing `@` (e.g. `users/foo@bar/profile`) are
1494/// preserved intact.
1495pub fn redact_url(url: &str) -> String {
1496    // Remove query parameters and fragments; split always yields >= 1 element.
1497    let base = url.split(['?', '#']).next().unwrap();
1498    // Only strip credentials from the authority section (between :// and
1499    // the next /). This avoids mangling paths like /users/foo@bar.
1500    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            // Only treat @ as credential separator if there's no / before it
1505            // in the authority section.
1506            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// ---------------------------------------------------------------------------
1516// Tests
1517// ---------------------------------------------------------------------------
1518
1519#[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    /// Guard the bit-width of `trace_id` against future narrowing.
1535    /// 64 bits makes birthday collisions plausible at telemetry scale.
1536    /// Compile-time assertion fires independent of whether this test
1537    /// runs; the runtime check observes the produced hex length so a
1538    /// byte-loop bug that silently truncates would still surface.
1539    #[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    /// `TelemetryContext::new` produces a `trace_id` of the same
1550    /// `TRACE_ID_LEN` lowercase-hex shape regardless of whether the
1551    /// task tuple is present or the session-less branch fires. Locks
1552    /// the cross-catalog uniformity that consumers rely on when
1553    /// parsing `trace_id` without first inspecting the variant.
1554    #[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        // Two session-less constructions must not alias on the same
1572        // trace_id (UUIDv4 entropy preserved through the digest).
1573        let sl2 = TelemetryContext::new("alice", None, None, None);
1574        assert_ne!(sl_trace, sl2.common().trace_id);
1575    }
1576
1577    // -----------------------------------------------------------------
1578    // TelemetryEmitterMux — multi-endpoint fan-out
1579    // -----------------------------------------------------------------
1580
1581    #[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        // emit on an empty mux must not panic — operators may
1589        // construct one transiently before adding endpoints.
1590        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        // YAML without an `endpoints:` block deserialises with an
1627        // empty endpoints list. Construction of the runtime mux
1628        // happens at config-load and validates non-empty when
1629        // `enabled` is true.
1630        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        // Validator runs before any emitter touch — exercising it
1639        // directly keeps the unit test sync (no NATS) while still
1640        // covering the duplicate-detection contract that
1641        // `TelemetryEmitterMux::new` enforces. Real-emitter coverage
1642        // lives in `tests/nats_integration/multi_endpoint_emit.rs`.
1643        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    /// `connect_endpoints` returns `Ok(None)` when telemetry is
1666    /// disabled — no NATS round-trip, no error.
1667    #[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    /// `connect_endpoints` returns `Ok(None)` when the endpoint list
1685    /// is empty — same fall-through semantics as disabled.
1686    #[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    /// Endpoint missing `nats_url` fails fast at the factory before
1699    /// attempting any connect — catches typos in YAML where the field
1700    /// was omitted.
1701    #[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    /// Invalid `agent_id` (e.g. contains forbidden NATS subject
1722    /// characters) fails before any NATS call.
1723    #[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    // Happy-path connect (real NATS, asserts emitters publish to
1741    // both endpoints) lives in
1742    // `tests/nats_integration/multi_endpoint_emit.rs` alongside the
1743    // existing fan-out coverage.
1744
1745    // Fan-out behaviour against a real NATS connection
1746    // (`mux.emit` reaches every endpoint, `mux.emit_for` only one,
1747    // and `dropped_count` aggregates) lives in
1748    // `tests/nats_integration/multi_endpoint_emit.rs` — that's where
1749    // we have the runtime + mock subscribers to verify routing.
1750
1751    #[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    /// Length-prefixed encoding must keep the (job_id, agent_id)
1782    /// boundary unambiguous even when the identifiers contain the
1783    /// internal delimiter characters or each other's content.
1784    /// Without length prefixes, naive concatenation
1785    /// `{job}|{round}|{phase}|{agent}` could collide e.g. for
1786    /// `job_id="ab", agent_id="c"` vs `job_id="a", agent_id="bc"`
1787    /// (both yield `"ab|...|c"` if the delimiter ever leaks).
1788    #[test]
1789    fn trace_id_resists_delimiter_collision_attacks() {
1790        // Pair 1: same total bytes spanning the (job_id, agent_id)
1791        // boundary, partitioned differently.
1792        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        // Pair 2: an id that embeds the delimiter character used by
1797        // the encoder (`':'` and `'|'`). Length-prefixed encoding
1798        // keeps these distinct from any unprefixed form.
1799        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    /// Defence-in-depth: invalid agent ids must never produce a NATS
1820    /// subject. NATS forbidden chars (`.` `*` `>`), whitespace, and
1821    /// empty strings would silently reshape the subject hierarchy
1822    /// and break the JWT-bound `agent_id` position contract.
1823    #[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    /// Documents the contract that `TelemetryEmitter::emit` relies
1855    /// on: `tokio::runtime::Handle::try_current()` returns `Err`
1856    /// from a thread that has no active runtime, which lets the
1857    /// emitter degrade to a counted drop instead of panicking.
1858    #[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    // -----------------------------------------------------------------
1887    // PromptExposureDetected
1888    // -----------------------------------------------------------------
1889
1890    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        // Scalar fields land at top level (flattened common + own fields) so
1937        // Loki's `| json` filter can promote them without nesting.
1938        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    /// The variant must be reachable from `TelemetryEvent::kind()` with the
1946    /// stable `prompt_exposure_detected` tag. The existing
1947    /// `event_kind_covers_every_variant` test enforces the exhaustive
1948    /// invariant; this one pins the exact string the operator-facing
1949    /// subject hierarchy relies on.
1950    #[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    /// Category counts must sum to `hit_count`. Operators reason about the
1962    /// total across the breakdown — an invariant broken by an off-by-one in
1963    /// the detector would silently skew dashboards. Assert at the type
1964    /// layer so a future builder that drifts fails a test instead of
1965    /// production.
1966    #[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    /// `sample_hits` only carries dictionary-sourced labels (tag names,
1975    /// tool names, instruction phrases, acronyms the guardrail ships with).
1976    /// None of these are free-form user content. This test locks the
1977    /// expected prefix alphabet so a future detector that accidentally
1978    /// leaks proposal content into the sample array fails CI.
1979    #[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    /// `blocked=false` is a valid state: a detection can be *observed*
1995    /// (hits > 0) but fall under the `min_suspicion_score` threshold, so
1996    /// the guardrail lets the response through. Dashboards rely on this
1997    /// to compute false-positive rates, so the telemetry event must
1998    /// round-trip both states.
1999    #[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        // Type tag present + snake_case kind.
2033        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        // The `kind()` method is the ground truth for subject
2087        // derivation. Every variant must return a non-empty, stable,
2088        // lower-snake-case identifier.
2089        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        // Sanity: every sample produces the snake_case the serde tag
2256        // uses. We verify by round-tripping JSON and reading the
2257        // `type` field.
2258        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        // Every variant is distinct by discriminant.
2270        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    // -----------------------------------------------------------------------
2290    // PromptExposureDetected validation
2291    // -----------------------------------------------------------------------
2292
2293    #[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, // mismatch — sum is 4
2318            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            // Looks like raw content, not a dictionary label
2363            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    // -----------------------------------------------------------------------
2371    // Agent identity validation
2372    // -----------------------------------------------------------------------
2373
2374    #[test]
2375    fn event_agent_id_accessor() {
2376        // Agent events return their agent_id
2377        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        // Unit test the check that emit() delegates to.
2389        let src = TelemetrySource::Agent {
2390            agent_id: "CortexA".into(),
2391        };
2392
2393        // Mismatched event
2394        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        // Matching event
2413        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    // -----------------------------------------------------------------------
2433    // Redaction tests
2434    // -----------------------------------------------------------------------
2435
2436    #[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        // These should NOT trigger redaction — word-boundary matching
2454        // prevents substring false positives.
2455        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); // 50 + 3 for "..."
2476    }
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}