Changes: - Non-blocking coding-data sharing upsell banner - Consolidate remediation in Doctor - Auto mode defers fail-closed gate asks to the classifier - Coalesce marketplace list fetches - Allow removing a marketplace source by name - Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal) - Label failed workspace RPCs with error_kind - Drop redundant explicit tonic/prost deps from xai-grok-shell - Report real exit codes for completed background shells - Narrow the date-rollover reminder to date-bearing templates - Wire toolOverrides through the session and agent - Security: Bash(git:*) allowlist matches whole command chain by prefix - Split prompt-trigger telemetry and record classifier provenance - Raise connectors-manager timeout to 60s - Auto classifier honors recorded approvals for repeat actions - Apply doctor fixes in the TUI - Auto-mode classifier timeouts prompt instead of silently denying - Scope subagent completion drains to the owning session - Add the toolOverrides wire types - Set client_identifier=grok-agent-sdk - Accept both spellings of the workspace-teleport kill switch - Persist one-shot occurrence journal - Stop turns that poll the exact same tool call 16x in a row - Copy compaction checkpoint files when forking sessions - Auto-focus permission prompt from scrollback - Esc cancels the running turn in non-vim and minimal modes - List Ctrl+Z undo and redo in keyboard shortcuts - Out-of-process macOS mic capture - Show active auth mode on session-info - Install the npm binary under $GROK_HOME - Remove hover/click dead zones between dashboard items - Route startup warnings to doctor - Document [feedback.user] author identity config - Extend bang command timeout - Close combine-queued edit-hold race - Integrate relocation recovery - Expose privacy notice rollout flag - Break harness discovery ref cycle so connections can idle-evict - Shift/Alt+Enter inserts newline when editing a queued prompt - Gate project Claude permissions on folder trust - Echo response.create.event_id on response.created - Toast when session creation fails from disk full - Add shared test process lifecycle - Enable dynamic workflows by default - Add relocation transaction state machine - Add shared test sandbox - Surface auth failures on model-switch compact - Persist durable scheduler expiry - Confirm before removing extensions-modal items - Re-run compact and prompt after login when compact hit expired auth - Recap sends hosted tools under backend search
237 lines
9.3 KiB
Rust
237 lines
9.3 KiB
Rust
//! Telemetry-engine configuration.
|
|
//!
|
|
//! Extracted from `xai-grok-shell::agent::config` so the data-collector
|
|
//! engine can construct a [`TelemetryClient`](crate::client::TelemetryClient)
|
|
//! without a build-time dependency on the shell.
|
|
//!
|
|
//! Shell still re-exports these types from their original paths so existing
|
|
//! call sites (and `Config` derive impls) compile unchanged.
|
|
use serde::{Deserialize, Serialize};
|
|
/// Telemetry mode: `true`/`false` (legacy bool) or `"session_metrics"` (string).
|
|
///
|
|
/// - `Disabled` -- nothing sent (enterprise default)
|
|
/// - `SessionMetrics` -- metadata-only lifecycle events, no content
|
|
/// - `Enabled` -- full product telemetry (events + Mixpanel)
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum TelemetryMode {
|
|
#[default]
|
|
Disabled,
|
|
SessionMetrics,
|
|
Enabled,
|
|
}
|
|
impl TelemetryMode {
|
|
pub fn is_disabled(&self) -> bool {
|
|
matches!(self, Self::Disabled)
|
|
}
|
|
pub fn is_enabled(&self) -> bool {
|
|
matches!(self, Self::Enabled)
|
|
}
|
|
/// True for both `SessionMetrics` and `Enabled`.
|
|
pub fn session_metrics_enabled(&self) -> bool {
|
|
matches!(self, Self::SessionMetrics | Self::Enabled)
|
|
}
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
"1" | "true" | "yes" | "on" | "enabled" | "full" => Some(Self::Enabled),
|
|
"0" | "false" | "no" | "off" | "disabled" => Some(Self::Disabled),
|
|
"session-metrics" | "session_metrics" => Some(Self::SessionMetrics),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
impl std::fmt::Display for TelemetryMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Disabled => write!(f, "false"),
|
|
Self::SessionMetrics => write!(f, "session_metrics"),
|
|
Self::Enabled => write!(f, "true"),
|
|
}
|
|
}
|
|
}
|
|
impl From<bool> for TelemetryMode {
|
|
fn from(b: bool) -> Self {
|
|
if b { Self::Enabled } else { Self::Disabled }
|
|
}
|
|
}
|
|
impl serde::Serialize for TelemetryMode {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
match self {
|
|
Self::Disabled => serializer.serialize_bool(false),
|
|
Self::Enabled => serializer.serialize_bool(true),
|
|
Self::SessionMetrics => serializer.serialize_str("session_metrics"),
|
|
}
|
|
}
|
|
}
|
|
/// Wire format for `[features] telemetry`: accepts `true`, `false`, or `"session_metrics"`.
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(untagged)]
|
|
enum TelemetryModeValue {
|
|
Bool(bool),
|
|
Str(String),
|
|
}
|
|
impl<'de> serde::Deserialize<'de> for TelemetryMode {
|
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
match TelemetryModeValue::deserialize(deserializer)? {
|
|
TelemetryModeValue::Bool(b) => Ok(Self::from(b)),
|
|
TelemetryModeValue::Str(s) => Ok(Self::parse(&s).unwrap_or_else(|| {
|
|
tracing::warn!(
|
|
value = %s,
|
|
"TELEMETRY_MODE_UNKNOWN: unrecognized telemetry mode; treating as disabled",
|
|
);
|
|
Self::Disabled
|
|
})),
|
|
}
|
|
}
|
|
}
|
|
/// Parse an env var as a `TelemetryMode`. Returns `None` if unset or empty.
|
|
pub fn env_telemetry_mode(name: &str) -> Option<TelemetryMode> {
|
|
let value = std::env::var(name).ok()?;
|
|
TelemetryMode::parse(&value)
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TelemetryConfig {
|
|
/// Declared for `serde_ignored`. Actual toggle is `[features] telemetry`.
|
|
#[serde(default)]
|
|
pub enabled: Option<bool>,
|
|
pub events_url: Option<String>,
|
|
pub events_api_key: Option<String>,
|
|
pub mixpanel_token: Option<String>,
|
|
pub mixpanel_enabled: bool,
|
|
/// `None` = inherit from `[features] telemetry`. `Some(false)` = disable GCS uploads only.
|
|
pub trace_upload: Option<bool>,
|
|
/// External OTEL master switch (`= GROK_EXTERNAL_OTEL`, env wins).
|
|
pub otel_enabled: Option<bool>,
|
|
/// External OTEL metrics exporter: `otlp` | `console` | `none`.
|
|
pub otel_metrics_exporter: Option<String>,
|
|
/// External OTEL logs/events exporter: `otlp` | `console` | `none`.
|
|
pub otel_logs_exporter: Option<String>,
|
|
/// External OTLP base endpoint (`/v1/logs`, `/v1/metrics` appended for HTTP).
|
|
pub otel_endpoint: Option<String>,
|
|
/// External OTLP transport: `http/protobuf` | `grpc`.
|
|
#[serde(alias = "otel_transport")]
|
|
pub otel_protocol: Option<String>,
|
|
/// External OTEL content gate (admins can pin to `false` via requirements).
|
|
pub otel_log_user_prompts: Option<bool>,
|
|
/// External OTEL content gate (admins can pin to `false` via requirements).
|
|
pub otel_log_tool_details: Option<bool>,
|
|
}
|
|
fn internal_defaults() -> (Option<String>, Option<String>, Option<String>, bool) {
|
|
(None, None, None, false)
|
|
}
|
|
fn build_env_default(value: Option<&'static str>) -> Option<String> {
|
|
value
|
|
.map(str::trim)
|
|
.filter(|v| !v.is_empty())
|
|
.map(str::to_owned)
|
|
}
|
|
impl Default for TelemetryConfig {
|
|
fn default() -> Self {
|
|
let (baked_url, baked_key, baked_token, baked_enabled) = internal_defaults();
|
|
let build_url = build_env_default(option_env!("GROK_TELEMETRY_BUILD_EVENTS_URL"));
|
|
let build_key = build_env_default(option_env!("GROK_TELEMETRY_BUILD_EVENTS_API_KEY"));
|
|
let build_token = build_env_default(option_env!("GROK_TELEMETRY_BUILD_MIXPANEL_TOKEN"));
|
|
let mixpanel_enabled = baked_enabled || build_token.is_some();
|
|
let (events_url, events_api_key, mixpanel_token) = (
|
|
build_url.or(baked_url),
|
|
build_key.or(baked_key),
|
|
build_token.or(baked_token),
|
|
);
|
|
Self {
|
|
enabled: None,
|
|
events_url,
|
|
events_api_key,
|
|
mixpanel_token,
|
|
mixpanel_enabled,
|
|
trace_upload: None,
|
|
otel_enabled: None,
|
|
otel_metrics_exporter: None,
|
|
otel_logs_exporter: None,
|
|
otel_endpoint: None,
|
|
otel_protocol: None,
|
|
otel_log_user_prompts: None,
|
|
otel_log_tool_details: None,
|
|
}
|
|
}
|
|
}
|
|
impl TelemetryConfig {
|
|
pub fn apply_env_overrides(&mut self) {
|
|
self.normalize();
|
|
if let Some(value) = Self::env_override("GROK_TELEMETRY_EVENTS_URL") {
|
|
self.events_url = value;
|
|
}
|
|
if let Some(value) = Self::env_override("GROK_TELEMETRY_EVENTS_API_KEY") {
|
|
self.events_api_key = value;
|
|
}
|
|
if let Some(value) = Self::env_override("GROK_TELEMETRY_MIXPANEL_TOKEN") {
|
|
self.mixpanel_token = value;
|
|
}
|
|
if let Some(value) = env_bool("GROK_TELEMETRY_MIXPANEL_ENABLED") {
|
|
self.mixpanel_enabled = value;
|
|
}
|
|
if let Some(value) = env_bool("GROK_TELEMETRY_TRACE_UPLOAD") {
|
|
self.trace_upload = Some(value);
|
|
}
|
|
}
|
|
fn normalize(&mut self) {
|
|
self.events_url = Self::normalize_optional_string(self.events_url.take());
|
|
self.events_api_key = Self::normalize_optional_string(self.events_api_key.take());
|
|
self.mixpanel_token = Self::normalize_optional_string(self.mixpanel_token.take());
|
|
}
|
|
fn env_override(name: &str) -> Option<Option<String>> {
|
|
match std::env::var(name) {
|
|
Ok(value) => Some(Self::normalize_optional_string(Some(value))),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
|
value.and_then(|raw| {
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
/// Parse an env var as a boolean. Returns `None` if unset or unrecognized.
|
|
///
|
|
/// Local copy of `xai_grok_shell::agent::config::env_bool` so this crate
|
|
/// stays free of a shell back-edge. Shell keeps its own copy for callers
|
|
/// outside the telemetry config path.
|
|
fn env_bool(name: &str) -> Option<bool> {
|
|
let value = std::env::var(name).ok()?;
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
"" => None,
|
|
"1" | "true" | "yes" | "on" | "enabled" => Some(true),
|
|
"0" | "false" | "no" | "off" | "disabled" => Some(false),
|
|
_ => None,
|
|
}
|
|
}
|
|
/// Derive a stable deployment ID (UUIDv5) from the deployment key.
|
|
pub fn deployment_id_from_key(key: &str) -> String {
|
|
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key.as_bytes()).to_string()
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn build_env_default_normalizes() {
|
|
assert_eq!(build_env_default(None), None);
|
|
assert_eq!(build_env_default(Some("")), None);
|
|
assert_eq!(build_env_default(Some(" \t ")), None);
|
|
assert_eq!(build_env_default(Some(" key ")), Some("key".to_owned()));
|
|
}
|
|
#[test]
|
|
fn default_is_build_env_layer_when_feature_off() {
|
|
let cfg = TelemetryConfig::default();
|
|
let url = build_env_default(option_env!("GROK_TELEMETRY_BUILD_EVENTS_URL"));
|
|
let key = build_env_default(option_env!("GROK_TELEMETRY_BUILD_EVENTS_API_KEY"));
|
|
let token = build_env_default(option_env!("GROK_TELEMETRY_BUILD_MIXPANEL_TOKEN"));
|
|
assert_eq!(cfg.mixpanel_enabled, token.is_some());
|
|
assert_eq!(cfg.events_url, url);
|
|
assert_eq!(cfg.events_api_key, key);
|
|
assert_eq!(cfg.mixpanel_token, token);
|
|
}
|
|
}
|