#![forbid(unsafe_code)]
use std::env;
use std::fmt;
#[derive(Debug, Clone)]
pub struct TelemetryConfig {
pub enabled: bool,
pub enabled_reason: EnabledReason,
pub endpoint: Option<String>,
pub endpoint_source: EndpointSource,
pub protocol: Protocol,
pub processor: SpanProcessorKind,
pub service_name: Option<String>,
pub resource_attributes: Vec<(String, String)>,
pub trace_id: Option<TraceId>,
pub parent_span_id: Option<SpanId>,
pub trace_context_source: TraceContextSource,
pub headers: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnabledReason {
SdkDisabled,
ExporterNone,
ExplicitOtlp,
EndpointSet,
FtuiEndpointSet,
DefaultDisabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndpointSource {
TracesEndpoint,
FtuiOverride,
BaseEndpoint,
ProtocolDefault,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Protocol {
#[default]
HttpProtobuf,
}
impl Protocol {
fn default_endpoint(self) -> &'static str {
match self {
Self::HttpProtobuf => "http://localhost:4318",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpanProcessorKind {
#[default]
Batch,
Simple,
}
impl SpanProcessorKind {
fn from_env(value: Option<String>) -> Self {
match value
.as_deref()
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Some("simple") => Self::Simple,
Some("batch") => Self::Batch,
_ => Self::default(),
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Batch => "batch",
Self::Simple => "simple",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraceContextSource {
Explicit,
New,
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceId([u8; 16]);
impl TraceId {
pub fn parse(s: &str) -> Option<Self> {
if s.len() != 32
|| !s
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return None;
}
let mut bytes = [0u8; 16];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
let hex_str = std::str::from_utf8(chunk).ok()?;
bytes[i] = u8::from_str_radix(hex_str, 16).ok()?;
}
if bytes.iter().all(|&b| b == 0) {
return None;
}
Some(Self(bytes))
}
pub fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpanId([u8; 8]);
impl SpanId {
pub fn parse(s: &str) -> Option<Self> {
if s.len() != 16
|| !s
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return None;
}
let mut bytes = [0u8; 8];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
let hex_str = std::str::from_utf8(chunk).ok()?;
bytes[i] = u8::from_str_radix(hex_str, 16).ok()?;
}
if bytes.iter().all(|&b| b == 0) {
return None;
}
Some(Self(bytes))
}
pub fn as_bytes(&self) -> &[u8; 8] {
&self.0
}
}
#[derive(Debug)]
pub enum TelemetryError {
SubscriberAlreadySet,
ExporterInit(String),
ProviderSetup(String),
}
impl fmt::Display for TelemetryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SubscriberAlreadySet => write!(
f,
"A global tracing subscriber is already set. Use build_layer() instead."
),
Self::ExporterInit(msg) => write!(f, "Failed to initialize OTLP exporter: {msg}"),
Self::ProviderSetup(msg) => write!(f, "Failed to set up tracer provider: {msg}"),
}
}
}
impl std::error::Error for TelemetryError {}
#[must_use = "TelemetryGuard must be held until shutdown to ensure spans are flushed"]
pub struct TelemetryGuard {
provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
_marker: std::marker::PhantomData<*const ()>,
}
impl TelemetryGuard {
fn new(provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>) -> Self {
Self {
provider,
_marker: std::marker::PhantomData,
}
}
}
impl Drop for TelemetryGuard {
fn drop(&mut self) {
if let Some(provider) = self.provider.take() {
let _ = provider.shutdown();
}
tracing::debug!("TelemetryGuard dropped, flushing spans");
}
}
impl TelemetryConfig {
pub fn from_env() -> Self {
Self::from_env_with(|key| env::var(key).ok())
}
pub fn from_env_with<F>(mut get: F) -> Self
where
F: FnMut(&str) -> Option<String>,
{
let exporter_raw = get("OTEL_TRACES_EXPORTER");
let exporters = exporter_raw
.as_deref()
.map(Self::parse_exporter_list)
.unwrap_or_default();
if get("OTEL_SDK_DISABLED")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
{
return Self::disabled(EnabledReason::SdkDisabled);
}
if exporters.len() == 1 && exporters[0] == "none" {
return Self::disabled(EnabledReason::ExporterNone);
}
let explicit_otlp = exporters.iter().any(|v| v == "otlp");
let has_otel_endpoint = get("OTEL_EXPORTER_OTLP_ENDPOINT").is_some();
let has_ftui_endpoint = get("FTUI_OTEL_HTTP_ENDPOINT").is_some();
let (enabled, enabled_reason) = if explicit_otlp {
(true, EnabledReason::ExplicitOtlp)
} else if has_ftui_endpoint {
(true, EnabledReason::FtuiEndpointSet)
} else if has_otel_endpoint {
(true, EnabledReason::EndpointSet)
} else {
(false, EnabledReason::DefaultDisabled)
};
if !enabled {
return Self::disabled(enabled_reason);
}
let protocol = Protocol::HttpProtobuf;
let (endpoint, endpoint_source) =
if let Some(ep) = get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") {
(Some(ep), EndpointSource::TracesEndpoint)
} else if let Some(ep) = get("FTUI_OTEL_HTTP_ENDPOINT") {
(Some(ep), EndpointSource::FtuiOverride)
} else if let Some(ep) = get("OTEL_EXPORTER_OTLP_ENDPOINT") {
(Some(ep), EndpointSource::BaseEndpoint)
} else {
(
Some(protocol.default_endpoint().to_string()),
EndpointSource::ProtocolDefault,
)
};
let trace_id = get("OTEL_TRACE_ID").and_then(|s| TraceId::parse(&s));
let parent_span_id = get("OTEL_PARENT_SPAN_ID").and_then(|s| SpanId::parse(&s));
let trace_context_source = if trace_id.is_some() && parent_span_id.is_some() {
TraceContextSource::Explicit
} else {
TraceContextSource::New
};
let service_name = get("OTEL_SERVICE_NAME");
let resource_attributes = Self::parse_kv_list(&get("OTEL_RESOURCE_ATTRIBUTES"));
let headers = Self::parse_kv_list(&get("OTEL_EXPORTER_OTLP_HEADERS"));
let processor = SpanProcessorKind::from_env(
get("FTUI_OTEL_SPAN_PROCESSOR").or_else(|| get("FTUI_OTEL_PROCESSOR")),
);
Self {
enabled,
enabled_reason,
endpoint,
endpoint_source,
protocol,
processor,
service_name,
resource_attributes,
trace_id,
parent_span_id,
trace_context_source,
headers,
}
}
fn disabled(reason: EnabledReason) -> Self {
Self {
enabled: false,
enabled_reason: reason,
endpoint: None,
endpoint_source: EndpointSource::None,
protocol: Protocol::default(),
processor: SpanProcessorKind::default(),
service_name: None,
resource_attributes: vec![],
trace_id: None,
parent_span_id: None,
trace_context_source: TraceContextSource::Disabled,
headers: vec![],
}
}
fn parse_kv_list(s: &Option<String>) -> Vec<(String, String)> {
s.as_ref()
.map(|s| {
s.split(',')
.filter_map(|kv| {
let mut parts = kv.splitn(2, '=');
let key = parts.next()?.trim();
let value = parts.next()?.trim();
if key.is_empty() {
None
} else {
Some((key.to_string(), value.to_string()))
}
})
.collect()
})
.unwrap_or_default()
}
fn parse_exporter_list(input: &str) -> Vec<String> {
input
.split(',')
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect()
}
#[inline]
pub fn is_enabled(&self) -> bool {
self.enabled
}
#[cfg(feature = "telemetry")]
pub fn install(self) -> Result<TelemetryGuard, TelemetryError> {
if !self.enabled {
tracing::debug!(reason = ?self.enabled_reason, "Telemetry disabled");
return Ok(TelemetryGuard::new(None));
}
let endpoint = self.endpoint.clone();
let protocol = self.protocol;
let trace_context = self.trace_context_source;
let (otel_layer, provider) = self.build_layer()?;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let subscriber = tracing_subscriber::registry().with(otel_layer);
if subscriber.try_init().is_err() {
let _ = provider.shutdown();
return Err(TelemetryError::SubscriberAlreadySet);
}
tracing::info!(
endpoint = ?endpoint,
protocol = ?protocol,
trace_context = ?trace_context,
"Telemetry installed"
);
Ok(TelemetryGuard::new(Some(provider)))
}
#[cfg(feature = "telemetry")]
pub fn build_layer(
self,
) -> Result<
(
tracing_opentelemetry::OpenTelemetryLayer<
tracing_subscriber::Registry,
opentelemetry_sdk::trace::Tracer,
>,
opentelemetry_sdk::trace::SdkTracerProvider,
),
TelemetryError,
> {
use opentelemetry::KeyValue;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::{Protocol as OtlpProtocol, WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::trace::SdkTracerProvider;
use std::collections::HashMap;
if !self.enabled {
return Err(TelemetryError::ExporterInit(
"Telemetry is disabled".to_string(),
));
}
let endpoint = self
.endpoint
.as_deref()
.unwrap_or(self.protocol.default_endpoint());
let mut resource_kvs = Vec::new();
if let Some(service_name) = &self.service_name {
resource_kvs.push(KeyValue::new(fields::SERVICE_NAME, service_name.clone()));
}
if !self.resource_attributes.is_empty() {
let override_service_name = self.service_name.is_some();
for (key, value) in &self.resource_attributes {
if override_service_name && key == fields::SERVICE_NAME {
continue;
}
resource_kvs.push(KeyValue::new(key.clone(), value.clone()));
}
}
let exporter = {
let mut builder = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_protocol(OtlpProtocol::HttpBinary)
.with_endpoint(endpoint);
if !self.headers.is_empty() {
let headers: HashMap<String, String> = self
.headers
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
builder = builder.with_headers(headers);
}
builder
.build()
.map_err(|e: opentelemetry_otlp::ExporterBuildError| {
TelemetryError::ExporterInit(e.to_string())
})?
};
let mut provider_builder = SdkTracerProvider::builder();
if !resource_kvs.is_empty() {
let resource = Resource::builder_empty()
.with_attributes(resource_kvs)
.build();
provider_builder = provider_builder.with_resource(resource);
}
let provider = match self.processor {
SpanProcessorKind::Simple => provider_builder.with_simple_exporter(exporter).build(),
SpanProcessorKind::Batch => provider_builder.with_batch_exporter(exporter).build(),
};
let tracer = provider.tracer("ftui-runtime");
let layer = tracing_opentelemetry::layer().with_tracer(tracer);
Ok((layer, provider))
}
pub fn evidence_ledger(&self) -> EvidenceLedger {
EvidenceLedger {
enabled: self.enabled,
enabled_reason: self.enabled_reason,
endpoint_source: self.endpoint_source,
protocol: self.protocol,
processor: self.processor,
trace_context_source: self.trace_context_source,
service_name: self.service_name.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct EvidenceLedger {
pub enabled: bool,
pub enabled_reason: EnabledReason,
pub endpoint_source: EndpointSource,
pub protocol: Protocol,
pub processor: SpanProcessorKind,
pub trace_context_source: TraceContextSource,
pub service_name: Option<String>,
}
pub const SCHEMA_VERSION: &str = "1.0.0";
#[derive(Debug, Clone)]
pub struct DecisionEvidence {
pub rule: String,
pub inputs_summary: String,
pub action: String,
pub confidence: Option<f32>,
pub alternatives: Vec<String>,
pub explanation: String,
}
impl DecisionEvidence {
pub fn simple(rule: impl Into<String>, action: impl Into<String>) -> Self {
Self {
rule: rule.into(),
inputs_summary: String::new(),
action: action.into(),
confidence: None,
alternatives: vec![],
explanation: String::new(),
}
}
#[must_use]
pub fn with_explanation(mut self, explanation: impl Into<String>) -> Self {
self.explanation = explanation.into();
self
}
#[must_use]
pub fn with_confidence(mut self, confidence: f32) -> Self {
self.confidence = Some(confidence.clamp(0.0, 1.0));
self
}
#[must_use]
pub fn with_alternatives(mut self, alternatives: Vec<String>) -> Self {
self.alternatives = alternatives;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DecisionDomain {
DiffStrategy,
ResizeCoalescing,
FrameBudget,
Degradation,
VOISampling,
HintRanking,
PaletteScoring,
}
impl DecisionDomain {
pub const fn as_str(self) -> &'static str {
match self {
Self::DiffStrategy => "diff_strategy",
Self::ResizeCoalescing => "resize_coalescing",
Self::FrameBudget => "frame_budget",
Self::Degradation => "degradation",
Self::VOISampling => "voi_sampling",
Self::HintRanking => "hint_ranking",
Self::PaletteScoring => "palette_scoring",
}
}
}
#[derive(Debug, Clone)]
pub struct EvidenceTerm {
pub label: String,
pub log_likelihood_ratio: f64,
}
impl EvidenceTerm {
pub fn new(label: impl Into<String>, log_likelihood_ratio: f64) -> Self {
Self {
label: label.into(),
log_likelihood_ratio,
}
}
}
#[derive(Debug, Clone)]
pub struct BayesianEvidence {
pub decision_id: String,
pub timestamp_ns: u64,
pub domain: DecisionDomain,
pub prior_log_odds: f64,
pub evidence_terms: Vec<EvidenceTerm>,
pub posterior_log_odds: f64,
pub action: String,
pub expected_loss: f64,
pub confidence_level: f64,
pub fallback_triggered: bool,
}
impl BayesianEvidence {
pub fn to_jsonl(&self) -> String {
let terms: Vec<String> = self
.evidence_terms
.iter()
.map(|t| {
format!(
"{{\"label\":\"{}\",\"llr\":{:.6}}}",
t.label.replace('"', "\\\""),
t.log_likelihood_ratio
)
})
.collect();
format!(
"{{\"id\":\"{}\",\"ts_ns\":{},\"domain\":\"{}\",\"prior\":{:.6},\"evidence\":[{}],\"posterior\":{:.6},\"action\":\"{}\",\"loss\":{:.6},\"confidence\":{:.6},\"fallback\":{}}}",
self.decision_id.replace('"', "\\\""),
self.timestamp_ns,
self.domain.as_str(),
self.prior_log_odds,
terms.join(","),
self.posterior_log_odds,
self.action.replace('"', "\\\""),
self.expected_loss,
self.confidence_level,
self.fallback_triggered,
)
}
}
pub mod spans {
pub const PROGRAM_INIT: &str = "ftui.program.init";
pub const PROGRAM_UPDATE: &str = "ftui.program.update";
pub const PROGRAM_VIEW: &str = "ftui.program.view";
pub const PROGRAM_SUBSCRIPTIONS: &str = "ftui.program.subscriptions";
pub const RENDER_FRAME: &str = "ftui.render.frame";
pub const RENDER_DIFF: &str = "ftui.render.diff";
pub const RENDER_PRESENT: &str = "ftui.render.present";
pub const RENDER_FLUSH: &str = "ftui.render.flush";
pub const INPUT_EVENT: &str = "ftui.input.event";
pub const INPUT_MACRO: &str = "ftui.input.macro";
}
pub mod events {
pub const DECISION_DEGRADATION: &str = "ftui.decision.degradation";
pub const DECISION_FALLBACK: &str = "ftui.decision.fallback";
pub const DECISION_RESIZE: &str = "ftui.decision.resize";
pub const DECISION_SCREEN_MODE: &str = "ftui.decision.screen_mode";
}
pub mod fields {
pub const SERVICE_NAME: &str = "service.name";
pub const SERVICE_VERSION: &str = "service.version";
pub const TELEMETRY_SDK: &str = "telemetry.sdk";
pub const HOST_ARCH: &str = "host.arch";
pub const PROCESS_PID: &str = "process.pid";
pub const SCHEMA_VERSION: &str = "ftui.schema_version";
pub const DURATION_US: &str = "duration_us";
pub const MODEL_TYPE: &str = "model_type";
pub const CMD_COUNT: &str = "cmd_count";
pub const MSG_TYPE: &str = "msg_type";
pub const CMD_TYPE: &str = "cmd_type";
pub const WIDGET_COUNT: &str = "widget_count";
pub const ACTIVE_COUNT: &str = "active_count";
pub const STARTED: &str = "started";
pub const STOPPED: &str = "stopped";
pub const WIDTH: &str = "width";
pub const HEIGHT: &str = "height";
pub const CHANGES_COUNT: &str = "changes_count";
pub const ROWS_SKIPPED: &str = "rows_skipped";
pub const BYTES_WRITTEN: &str = "bytes_written";
pub const RUNS_COUNT: &str = "runs_count";
pub const SYNC_MODE: &str = "sync_mode";
pub const LEVEL: &str = "level";
pub const REASON: &str = "reason";
pub const BUDGET_REMAINING: &str = "budget_remaining";
pub const CAPABILITY: &str = "capability";
pub const FALLBACK_TO: &str = "fallback_to";
pub const STRATEGY: &str = "strategy";
pub const DEBOUNCE_ACTIVE: &str = "debounce_active";
pub const COALESCED: &str = "coalesced";
pub const MODE: &str = "mode";
pub const UI_HEIGHT: &str = "ui_height";
pub const ANCHOR: &str = "anchor";
pub const EVENT_TYPE: &str = "event_type";
pub const MACRO_ID: &str = "macro_id";
pub const EVENT_COUNT: &str = "event_count";
pub const DECISION_RULE: &str = "decision.rule";
pub const DECISION_INPUTS: &str = "decision.inputs";
pub const DECISION_ACTION: &str = "decision.action";
pub const DECISION_CONFIDENCE: &str = "decision.confidence";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedactionLevel {
Full,
Partial,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataCategory {
UserInput,
FilePath,
EnvVar,
MemoryAddress,
ProcessArgs,
UserIdentifier,
WidgetType,
MessageType,
CommandType,
CapabilityDetails,
}
impl DataCategory {
#[must_use]
pub const fn default_redaction(self) -> RedactionLevel {
match self {
Self::UserInput
| Self::FilePath
| Self::EnvVar
| Self::MemoryAddress
| Self::ProcessArgs
| Self::UserIdentifier => RedactionLevel::Full,
Self::WidgetType | Self::MessageType | Self::CommandType | Self::CapabilityDetails => {
RedactionLevel::Partial
}
}
}
#[must_use]
pub fn should_redact(self) -> bool {
match self.default_redaction() {
RedactionLevel::Full => true,
RedactionLevel::Partial => !redact::is_verbose(),
RedactionLevel::None => false,
}
}
}
pub const ALLOWED_ENV_PREFIXES: &[&str] = &["OTEL_", "FTUI_"];
#[must_use]
pub fn is_safe_env_var(name: &str) -> bool {
ALLOWED_ENV_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
}
pub mod redact {
use std::path::Path;
#[inline]
pub fn path(_path: &Path) -> &'static str {
"[redacted:path]"
}
#[inline]
pub fn content(_content: &str) -> &'static str {
"[redacted:content]"
}
#[inline]
pub fn address<T>(_ptr: *const T) -> &'static str {
"[redacted:address]"
}
#[inline]
pub fn env_var(_value: &str) -> &'static str {
"[redacted:env]"
}
#[inline]
pub fn process_args(_args: &[String]) -> &'static str {
"[redacted:args]"
}
#[inline]
pub fn username(_name: &str) -> &'static str {
"[redacted:user]"
}
#[inline]
pub fn count<T>(items: &[T]) -> String {
format!("{} items", items.len())
}
#[inline]
pub fn bytes(size: usize) -> String {
if size < 1024 {
format!("{size} B")
} else if size < 1024 * 1024 {
format!("{:.1} KB", size as f64 / 1024.0)
} else {
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
}
}
#[inline]
pub fn duration_us(micros: u64) -> String {
if micros < 1000 {
format!("{micros}μs")
} else if micros < 1_000_000 {
format!("{:.2}ms", micros as f64 / 1000.0)
} else {
format!("{:.2}s", micros as f64 / 1_000_000.0)
}
}
#[inline]
pub fn dimensions(width: u16, height: u16) -> String {
format!("{width}x{height}")
}
#[inline]
pub fn is_verbose() -> bool {
std::env::var("FTUI_TELEMETRY_VERBOSE")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
#[inline]
pub fn if_verbose<T: Default>(value: T) -> T {
if is_verbose() { value } else { T::default() }
}
#[inline]
pub fn verbose_str(value: &str) -> &str {
if is_verbose() {
value
} else {
"[verbose-only]"
}
}
#[inline]
pub fn type_name(name: &str) -> &str {
if is_verbose() { name } else { "[type]" }
}
#[inline]
pub fn is_valid_custom_field(name: &str) -> bool {
name.starts_with("app.") || name.starts_with("custom.")
}
pub fn prefix_custom_field(name: &str) -> String {
if is_valid_custom_field(name) {
name.to_string()
} else {
format!("app.{name}")
}
}
pub fn contains_sensitive_pattern(s: &str) -> bool {
let lower = s.to_lowercase();
lower.contains("password")
|| lower.contains("secret")
|| lower.contains("token")
|| lower.contains("key=")
|| lower.contains("api_key")
|| lower.contains("auth")
|| s.contains('@') || s.starts_with('/') || s.contains("://") }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trace_id_parse_valid() {
let id = TraceId::parse("0123456789abcdef0123456789abcdef");
assert!(id.is_some());
}
#[test]
fn test_trace_id_parse_invalid_length() {
assert!(TraceId::parse("0123456789abcdef").is_none());
assert!(TraceId::parse("0123456789abcdef0123456789abcdef00").is_none());
}
#[test]
fn test_trace_id_parse_invalid_uppercase() {
assert!(TraceId::parse("0123456789ABCDEF0123456789abcdef").is_none());
}
#[test]
fn test_trace_id_parse_all_zeros() {
assert!(TraceId::parse("00000000000000000000000000000000").is_none());
}
#[test]
fn test_span_id_parse_valid() {
let id = SpanId::parse("0123456789abcdef");
assert!(id.is_some());
}
#[test]
fn test_span_id_parse_invalid() {
assert!(SpanId::parse("012345").is_none());
assert!(SpanId::parse("0123456789ABCDEF").is_none());
assert!(SpanId::parse("0000000000000000").is_none());
}
#[test]
fn test_parse_kv_list() {
let result = TelemetryConfig::parse_kv_list(&Some("a=b,c=d".to_string()));
assert_eq!(
result,
vec![
("a".to_string(), "b".to_string()),
("c".to_string(), "d".to_string()),
]
);
}
#[test]
fn test_parse_kv_list_with_spaces() {
let result = TelemetryConfig::parse_kv_list(&Some(" key = value , k2=v2 ".to_string()));
assert_eq!(
result,
vec![
("key".to_string(), "value".to_string()),
("k2".to_string(), "v2".to_string()),
]
);
}
#[test]
fn test_parse_kv_list_empty() {
let result = TelemetryConfig::parse_kv_list(&None);
assert!(result.is_empty());
}
#[test]
fn test_protocol_default_endpoint() {
assert_eq!(
Protocol::HttpProtobuf.default_endpoint(),
"http://localhost:4318"
);
}
#[test]
fn test_disabled_config() {
let config = TelemetryConfig::disabled(EnabledReason::SdkDisabled);
assert!(!config.is_enabled());
assert_eq!(config.enabled_reason, EnabledReason::SdkDisabled);
assert_eq!(config.trace_context_source, TraceContextSource::Disabled);
}
#[test]
fn test_schema_version() {
assert_eq!(SCHEMA_VERSION, "1.0.0");
}
#[test]
fn test_decision_evidence_simple() {
let evidence = DecisionEvidence::simple("degradation_rule", "reduce_to_skeleton");
assert_eq!(evidence.rule, "degradation_rule");
assert_eq!(evidence.action, "reduce_to_skeleton");
assert!(evidence.confidence.is_none());
assert!(evidence.alternatives.is_empty());
}
#[test]
fn test_decision_evidence_builder() {
let evidence = DecisionEvidence::simple("fallback_rule", "use_ascii")
.with_explanation("Terminal does not support Unicode")
.with_confidence(0.95)
.with_alternatives(vec!["use_emoji".to_string(), "skip_render".to_string()]);
assert_eq!(evidence.explanation, "Terminal does not support Unicode");
assert_eq!(evidence.confidence, Some(0.95));
assert_eq!(evidence.alternatives.len(), 2);
}
#[test]
fn test_decision_evidence_confidence_clamped() {
let evidence = DecisionEvidence::simple("test", "test").with_confidence(1.5);
assert_eq!(evidence.confidence, Some(1.0));
let evidence = DecisionEvidence::simple("test", "test").with_confidence(-0.5);
assert_eq!(evidence.confidence, Some(0.0));
}
#[test]
fn test_redact_path() {
use std::path::Path;
let path_str = ["/home/user/", "sec", "ret", ".txt"].concat();
assert_eq!(redact::path(Path::new(&path_str)), "[redacted:path]");
}
#[test]
fn test_redact_content() {
assert_eq!(redact::content("sensitive data"), "[redacted:content]");
}
#[test]
fn test_redact_count() {
let items = vec![1, 2, 3, 4, 5];
assert_eq!(redact::count(&items), "5 items");
}
#[test]
fn test_redact_bytes() {
assert_eq!(redact::bytes(500), "500 B");
assert_eq!(redact::bytes(2048), "2.0 KB");
assert_eq!(redact::bytes(1024 * 1024 + 512 * 1024), "1.5 MB");
}
#[test]
fn test_span_names_follow_convention() {
assert!(spans::PROGRAM_INIT.starts_with("ftui."));
assert!(spans::PROGRAM_UPDATE.starts_with("ftui."));
assert!(spans::PROGRAM_VIEW.starts_with("ftui."));
assert!(spans::RENDER_FRAME.starts_with("ftui."));
assert!(spans::RENDER_DIFF.starts_with("ftui."));
assert!(spans::INPUT_EVENT.starts_with("ftui."));
}
#[test]
fn test_event_names_follow_convention() {
assert!(events::DECISION_DEGRADATION.starts_with("ftui.decision."));
assert!(events::DECISION_FALLBACK.starts_with("ftui.decision."));
assert!(events::DECISION_RESIZE.starts_with("ftui.decision."));
assert!(events::DECISION_SCREEN_MODE.starts_with("ftui.decision."));
}
#[test]
fn test_field_names_are_lowercase_with_dots() {
let check_field = |name: &str| {
assert!(
name.chars()
.all(|c| c.is_ascii_lowercase() || c == '.' || c == '_'),
"Field name '{}' contains invalid characters",
name
);
};
check_field(fields::DURATION_US);
check_field(fields::WIDTH);
check_field(fields::HEIGHT);
check_field(fields::DECISION_RULE);
check_field(fields::SERVICE_NAME);
}
#[test]
fn test_hard_redaction_categories() {
assert_eq!(
DataCategory::UserInput.default_redaction(),
RedactionLevel::Full
);
assert_eq!(
DataCategory::FilePath.default_redaction(),
RedactionLevel::Full
);
assert_eq!(
DataCategory::EnvVar.default_redaction(),
RedactionLevel::Full
);
assert_eq!(
DataCategory::MemoryAddress.default_redaction(),
RedactionLevel::Full
);
assert_eq!(
DataCategory::ProcessArgs.default_redaction(),
RedactionLevel::Full
);
assert_eq!(
DataCategory::UserIdentifier.default_redaction(),
RedactionLevel::Full
);
}
#[test]
fn test_soft_redaction_categories() {
assert_eq!(
DataCategory::WidgetType.default_redaction(),
RedactionLevel::Partial
);
assert_eq!(
DataCategory::MessageType.default_redaction(),
RedactionLevel::Partial
);
assert_eq!(
DataCategory::CommandType.default_redaction(),
RedactionLevel::Partial
);
assert_eq!(
DataCategory::CapabilityDetails.default_redaction(),
RedactionLevel::Partial
);
}
#[test]
fn test_hard_redaction_always_redacts() {
assert!(DataCategory::UserInput.should_redact());
assert!(DataCategory::FilePath.should_redact());
assert!(DataCategory::MemoryAddress.should_redact());
}
#[test]
fn test_safe_env_var_prefixes() {
assert!(is_safe_env_var("OTEL_EXPORTER_OTLP_ENDPOINT"));
assert!(is_safe_env_var("OTEL_SDK_DISABLED"));
assert!(is_safe_env_var("FTUI_TELEMETRY_VERBOSE"));
assert!(is_safe_env_var("FTUI_OTEL_HTTP_ENDPOINT"));
}
#[test]
fn test_unsafe_env_vars() {
assert!(!is_safe_env_var("HOME"));
assert!(!is_safe_env_var("PATH"));
assert!(!is_safe_env_var("AWS_SECRET_ACCESS_KEY"));
assert!(!is_safe_env_var("DATABASE_URL"));
}
#[test]
fn test_redact_env_var() {
let secret_value = ["sec", "ret", "_value"].concat();
assert_eq!(redact::env_var(&secret_value), "[redacted:env]");
}
#[test]
fn test_redact_process_args() {
let secret_arg = ["sec", "ret", "123"].concat();
let args = vec!["--password".to_string(), secret_arg];
assert_eq!(redact::process_args(&args), "[redacted:args]");
}
#[test]
fn test_redact_username() {
assert_eq!(redact::username("john_doe"), "[redacted:user]");
}
#[test]
fn test_redact_duration_us() {
assert_eq!(redact::duration_us(500), "500μs");
assert_eq!(redact::duration_us(1500), "1.50ms");
assert_eq!(redact::duration_us(1_500_000), "1.50s");
}
#[test]
fn test_redact_dimensions() {
assert_eq!(redact::dimensions(80, 24), "80x24");
assert_eq!(redact::dimensions(120, 40), "120x40");
}
#[test]
fn test_verbose_str() {
assert_eq!(redact::verbose_str("WidgetType"), "[verbose-only]");
}
#[test]
fn test_type_name_redaction() {
assert_eq!(redact::type_name("MyWidget"), "[type]");
}
#[test]
fn test_valid_custom_field() {
assert!(redact::is_valid_custom_field("app.my_field"));
assert!(redact::is_valid_custom_field("custom.my_field"));
assert!(!redact::is_valid_custom_field("my_field"));
assert!(!redact::is_valid_custom_field("ftui.internal"));
}
#[test]
fn test_prefix_custom_field() {
assert_eq!(redact::prefix_custom_field("app.my_field"), "app.my_field");
assert_eq!(
redact::prefix_custom_field("custom.my_field"),
"custom.my_field"
);
assert_eq!(redact::prefix_custom_field("my_field"), "app.my_field");
assert_eq!(
redact::prefix_custom_field("user_action"),
"app.user_action"
);
}
#[test]
fn test_contains_sensitive_pattern() {
let password_sample = ["pass", "word", "=", "sec", "ret"].concat();
assert!(redact::contains_sensitive_pattern(&password_sample));
let api_key_sample = ["API", "_", "KEY", "=", "abc123"].concat();
assert!(redact::contains_sensitive_pattern(&api_key_sample));
assert!(redact::contains_sensitive_pattern("auth_token"));
assert!(redact::contains_sensitive_pattern("user@example.com"));
let secret_path = ["/home/user/", "sec", "ret", ".txt"].concat();
assert!(redact::contains_sensitive_pattern(&secret_path));
assert!(redact::contains_sensitive_pattern(
"https://example.com/api"
));
assert!(!redact::contains_sensitive_pattern("frame_count"));
assert!(!redact::contains_sensitive_pattern("widget_type"));
assert!(!redact::contains_sensitive_pattern("duration_us"));
}
#[test]
fn test_schema_version_semver() {
let parts: Vec<&str> = SCHEMA_VERSION.split('.').collect();
assert_eq!(parts.len(), 3, "Schema version should have 3 parts");
for part in parts {
assert!(part.parse::<u32>().is_ok(), "Each part should be a number");
}
}
#[test]
fn test_redaction_placeholder_format() {
assert!(redact::path(std::path::Path::new("/")).starts_with("[redacted:"));
assert!(redact::content("").starts_with("[redacted:"));
assert!(redact::address(std::ptr::null::<u8>()).starts_with("[redacted:"));
assert!(redact::env_var("").starts_with("[redacted:"));
assert!(redact::process_args(&[]).starts_with("[redacted:"));
assert!(redact::username("").starts_with("[redacted:"));
}
#[test]
fn perf_telemetry_config_jsonl_budget() {
use std::io::Write as _;
use std::time::Instant;
const RUNS: usize = 40;
let mut jsonl = Vec::new();
let mut disabled = Vec::with_capacity(RUNS / 2 + 1);
let mut enabled = Vec::with_capacity(RUNS / 2 + 1);
for i in 0..RUNS {
let (label, config) = if i % 2 == 0 {
let start = Instant::now();
let config = TelemetryConfig::from_env_with(|_| None);
let elapsed_ns = start.elapsed().as_nanos() as u64;
disabled.push(elapsed_ns);
("disabled", config)
} else {
let start = Instant::now();
let config = TelemetryConfig::from_env_with(|key| match key {
"OTEL_TRACES_EXPORTER" => Some("otlp".into()),
"OTEL_EXPORTER_OTLP_ENDPOINT" => Some("http://localhost:4318".into()),
"OTEL_SERVICE_NAME" => Some("ftui-perf".into()),
_ => None,
});
let elapsed_ns = start.elapsed().as_nanos() as u64;
enabled.push(elapsed_ns);
("enabled_endpoint", config)
};
let ledger = config.evidence_ledger();
let checksum = {
let mut hash: u64 = 0xcbf29ce484222325;
let payload = format!(
"{:?}{:?}{:?}{:?}{:?}",
ledger.enabled,
ledger.enabled_reason,
ledger.endpoint_source,
ledger.protocol,
ledger.trace_context_source
);
for &b in payload.as_bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
};
writeln!(
&mut jsonl,
"{{\"test\":\"telemetry_config\",\"case\":\"{label}\",\
\"elapsed_ns\":{},\"enabled\":{},\"enabled_reason\":\"{:?}\",\
\"endpoint_source\":\"{:?}\",\"protocol\":\"{:?}\",\
\"trace_context_source\":\"{:?}\",\"checksum\":\"{checksum:016x}\"}}",
if label == "disabled" {
*disabled.last().unwrap()
} else {
*enabled.last().unwrap()
},
ledger.enabled,
ledger.enabled_reason,
ledger.endpoint_source,
ledger.protocol,
ledger.trace_context_source
)
.expect("jsonl write failed");
}
fn percentile(samples: &mut [u64], p: f64) -> u64 {
samples.sort_unstable();
let idx = ((samples.len() as f64 - 1.0) * p).round() as usize;
samples[idx]
}
let mut disabled_samples = disabled.clone();
let mut enabled_samples = enabled.clone();
let p95_disabled = percentile(&mut disabled_samples, 0.95);
let p95_enabled = percentile(&mut enabled_samples, 0.95);
let p99_disabled = percentile(&mut disabled_samples, 0.99);
let p99_enabled = percentile(&mut enabled_samples, 0.99);
let (budget_disabled_ns, budget_enabled_ns) = if cfg!(debug_assertions) {
(200_000, 400_000)
} else {
(5_000, 20_000)
};
assert!(
p95_disabled <= budget_disabled_ns,
"p95 disabled {p95_disabled}ns exceeds {budget_disabled_ns}ns"
);
assert!(
p95_enabled <= budget_enabled_ns,
"p95 enabled {p95_enabled}ns exceeds {budget_enabled_ns}ns"
);
assert!(
p99_disabled <= budget_disabled_ns * 2,
"p99 disabled {p99_disabled}ns exceeds 2x budget"
);
assert!(
p99_enabled <= budget_enabled_ns * 2,
"p99 enabled {p99_enabled}ns exceeds 2x budget"
);
let text = String::from_utf8(jsonl).expect("jsonl utf8");
print!("{text}");
assert_eq!(text.lines().count(), RUNS);
}
}
#[cfg(test)]
mod in_memory_exporter_tests {
use super::*;
use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider};
use std::collections::HashMap;
use tracing_subscriber::prelude::*;
type OtelLayer = tracing_opentelemetry::OpenTelemetryLayer<
tracing_subscriber::Registry,
opentelemetry_sdk::trace::Tracer,
>;
fn build_in_memory_provider() -> (InMemorySpanExporter, SdkTracerProvider) {
let exporter = InMemorySpanExporter::default();
let provider = SdkTracerProvider::builder()
.with_simple_exporter(exporter.clone())
.build();
(exporter, provider)
}
fn build_in_memory_layer(provider: &SdkTracerProvider) -> OtelLayer {
use opentelemetry::trace::TracerProvider as _;
let tracer = provider.tracer("ftui-runtime-test");
tracing_opentelemetry::layer().with_tracer(tracer)
}
#[test]
fn test_config_disabled_when_otel_sdk_disabled() {
let config = TelemetryConfig::disabled(EnabledReason::SdkDisabled);
assert!(!config.is_enabled());
assert_eq!(config.enabled_reason, EnabledReason::SdkDisabled);
assert_eq!(config.trace_context_source, TraceContextSource::Disabled);
}
#[test]
fn test_config_disabled_when_exporter_none() {
let config = TelemetryConfig::disabled(EnabledReason::ExporterNone);
assert!(!config.is_enabled());
assert_eq!(config.enabled_reason, EnabledReason::ExporterNone);
}
#[test]
fn test_config_disabled_by_default() {
let config = TelemetryConfig::disabled(EnabledReason::DefaultDisabled);
assert!(!config.is_enabled());
assert_eq!(config.enabled_reason, EnabledReason::DefaultDisabled);
}
#[test]
fn test_config_enables_with_otlp_in_exporter_list() {
let env = HashMap::from([(
"OTEL_TRACES_EXPORTER".to_string(),
"otlp,console".to_string(),
)]);
let config = TelemetryConfig::from_env_with(|key| env.get(key).cloned());
assert!(config.is_enabled());
assert_eq!(config.enabled_reason, EnabledReason::ExplicitOtlp);
}
#[test]
fn test_config_disabled_when_exporter_list_none_only() {
let env = HashMap::from([("OTEL_TRACES_EXPORTER".to_string(), "none".to_string())]);
let config = TelemetryConfig::from_env_with(|key| env.get(key).cloned());
assert!(!config.is_enabled());
assert_eq!(config.enabled_reason, EnabledReason::ExporterNone);
}
#[test]
fn test_span_processor_env_simple() {
let env = HashMap::from([
("OTEL_TRACES_EXPORTER".to_string(), "otlp".to_string()),
("FTUI_OTEL_SPAN_PROCESSOR".to_string(), "simple".to_string()),
]);
let config = TelemetryConfig::from_env_with(|key| env.get(key).cloned());
assert_eq!(config.processor, SpanProcessorKind::Simple);
}
#[test]
fn test_trace_id_parse_valid_format() {
let valid = "0123456789abcdef0123456789abcdef";
assert!(TraceId::parse(valid).is_some());
let valid2 = "abcdef0123456789abcdef0123456789";
assert!(TraceId::parse(valid2).is_some());
}
#[test]
fn test_trace_id_reject_invalid() {
assert!(TraceId::parse("0123456789abcdef").is_none());
assert!(TraceId::parse("0123456789abcdef0123456789abcdef00").is_none());
assert!(TraceId::parse("0123456789ABCDEF0123456789abcdef").is_none());
assert!(TraceId::parse("00000000000000000000000000000000").is_none());
assert!(TraceId::parse("gggggggggggggggggggggggggggggggg").is_none());
}
#[test]
fn test_span_id_parse_valid_format() {
let valid = "0123456789abcdef";
assert!(SpanId::parse(valid).is_some());
}
#[test]
fn test_span_id_reject_invalid() {
assert!(SpanId::parse("012345").is_none());
assert!(SpanId::parse("0123456789abcdef00").is_none());
assert!(SpanId::parse("0123456789ABCDEF").is_none());
assert!(SpanId::parse("0000000000000000").is_none());
}
#[test]
fn test_trace_context_requires_both_ids() {
let config_with_both = TelemetryConfig {
enabled: true,
enabled_reason: EnabledReason::ExplicitOtlp,
endpoint: Some("http://localhost:4318".to_string()),
endpoint_source: EndpointSource::ProtocolDefault,
protocol: Protocol::HttpProtobuf,
processor: SpanProcessorKind::Batch,
service_name: None,
resource_attributes: vec![],
trace_id: TraceId::parse("0123456789abcdef0123456789abcdef"),
parent_span_id: SpanId::parse("0123456789abcdef"),
trace_context_source: TraceContextSource::Explicit,
headers: vec![],
};
assert_eq!(
config_with_both.trace_context_source,
TraceContextSource::Explicit
);
assert!(config_with_both.trace_id.is_some());
assert!(config_with_both.parent_span_id.is_some());
}
#[test]
fn test_trace_context_new_when_ids_missing() {
let config_new = TelemetryConfig {
enabled: true,
enabled_reason: EnabledReason::ExplicitOtlp,
endpoint: Some("http://localhost:4318".to_string()),
endpoint_source: EndpointSource::ProtocolDefault,
protocol: Protocol::HttpProtobuf,
processor: SpanProcessorKind::Batch,
service_name: None,
resource_attributes: vec![],
trace_id: None,
parent_span_id: None,
trace_context_source: TraceContextSource::New,
headers: vec![],
};
assert_eq!(config_new.trace_context_source, TraceContextSource::New);
assert!(config_new.trace_id.is_none());
}
#[test]
fn test_in_memory_exporter_exports_spans_when_enabled() {
let env = HashMap::from([("OTEL_TRACES_EXPORTER".to_string(), "otlp".to_string())]);
let config = TelemetryConfig::from_env_with(|key| env.get(key).cloned());
assert!(config.is_enabled());
let (exporter, provider) = build_in_memory_provider();
let layer = build_in_memory_layer(&provider);
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info_span!("ftui.test.export").in_scope(|| {
tracing::info!("exported");
});
});
provider.force_flush().expect("force_flush failed");
let spans = exporter
.get_finished_spans()
.expect("failed to fetch spans");
assert!(spans.iter().any(|span| span.name == "ftui.test.export"));
}
#[test]
fn test_in_memory_exporter_silent_when_disabled() {
let env = HashMap::from([("OTEL_SDK_DISABLED".to_string(), "true".to_string())]);
let config = TelemetryConfig::from_env_with(|key| env.get(key).cloned());
assert!(!config.is_enabled());
let (exporter, provider) = build_in_memory_provider();
let subscriber = tracing_subscriber::registry();
tracing::subscriber::with_default(subscriber, || {
tracing::info_span!("ftui.test.disabled").in_scope(|| {
tracing::info!("not exported");
});
});
provider.force_flush().expect("force_flush failed");
let spans = exporter
.get_finished_spans()
.expect("failed to fetch spans");
assert!(spans.is_empty());
}
#[test]
fn test_evidence_ledger_captures_config() {
let config = TelemetryConfig {
enabled: true,
enabled_reason: EnabledReason::EndpointSet,
endpoint: Some("http://collector:4318".to_string()),
endpoint_source: EndpointSource::BaseEndpoint,
protocol: Protocol::HttpProtobuf,
processor: SpanProcessorKind::Batch,
service_name: Some("ftui-test".to_string()),
resource_attributes: vec![],
trace_id: None,
parent_span_id: None,
trace_context_source: TraceContextSource::New,
headers: vec![],
};
let ledger = config.evidence_ledger();
assert!(ledger.enabled);
assert_eq!(ledger.enabled_reason, EnabledReason::EndpointSet);
assert_eq!(ledger.endpoint_source, EndpointSource::BaseEndpoint);
assert_eq!(ledger.protocol, Protocol::HttpProtobuf);
assert_eq!(ledger.processor, SpanProcessorKind::Batch);
assert_eq!(ledger.service_name, Some("ftui-test".to_string()));
}
#[test]
fn test_http_uses_port_4318() {
assert_eq!(
Protocol::HttpProtobuf.default_endpoint(),
"http://localhost:4318"
);
}
#[test]
fn test_default_protocol_is_http() {
assert_eq!(Protocol::default(), Protocol::HttpProtobuf);
}
#[test]
fn test_disabled_config_has_no_overhead() {
let config = TelemetryConfig::disabled(EnabledReason::SdkDisabled);
assert!(!config.enabled);
assert!(config.endpoint.is_none());
assert!(config.trace_id.is_none());
assert!(config.parent_span_id.is_none());
assert!(config.service_name.is_none());
assert!(config.resource_attributes.is_empty());
assert!(config.headers.is_empty());
}
#[test]
fn test_kv_list_parse_multiple() {
let result = TelemetryConfig::parse_kv_list(&Some(
"service.name=ftui,env=prod,version=1.0".to_string(),
));
assert_eq!(result.len(), 3);
assert_eq!(result[0], ("service.name".to_string(), "ftui".to_string()));
assert_eq!(result[1], ("env".to_string(), "prod".to_string()));
assert_eq!(result[2], ("version".to_string(), "1.0".to_string()));
}
#[test]
fn test_kv_list_handles_empty_values() {
let result = TelemetryConfig::parse_kv_list(&Some("key=".to_string()));
assert_eq!(result.len(), 1);
assert_eq!(result[0], ("key".to_string(), "".to_string()));
}
#[test]
fn test_kv_list_skips_malformed() {
let result =
TelemetryConfig::parse_kv_list(&Some("valid=value,malformed,another=good".to_string()));
assert_eq!(result.len(), 2);
assert_eq!(result[0].0, "valid");
assert_eq!(result[1].0, "another");
}
}