use std::time::{Duration, Instant};
use cua_telemetry::Captured;
use cua_telemetry::events::{self, Outcome};
use crate::{CuaError, Result};
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct TelemetryStatus {
pub enabled: bool,
pub source: String,
pub source_kind: String,
pub is_ci: bool,
pub install_id: Option<String>,
pub notice_shown: bool,
pub endpoint: String,
pub product: String,
pub envelope_json: String,
}
fn io(e: impl std::fmt::Display) -> CuaError {
CuaError::Internal(e.to_string())
}
fn t() -> &'static cua_telemetry::Telemetry {
cua_telemetry::global()
}
#[uniffi::export]
pub fn telemetry_status() -> TelemetryStatus {
let s = t().status();
TelemetryStatus {
enabled: s.enabled,
source: s.source,
source_kind: s.source_kind,
is_ci: s.is_ci,
install_id: s.install_id,
notice_shown: s.notice_shown,
endpoint: s.endpoint,
product: s.product,
envelope_json: serde_json::Value::Object(t().envelope_preview()).to_string(),
}
}
#[uniffi::export]
pub fn telemetry_set_enabled(enabled: bool) -> Result<TelemetryStatus> {
t().set_enabled(enabled).map_err(io)?;
Ok(telemetry_status())
}
#[uniffi::export]
pub fn telemetry_reset_id() -> Result<()> {
t().reset_id().map(|_| ()).map_err(io)
}
#[uniffi::export]
pub fn telemetry_show_last(limit: u32) -> String {
serde_json::Value::Array(t().show_last(limit as usize)).to_string()
}
#[uniffi::export]
pub fn telemetry_schema_json() -> String {
cua_telemetry::schema::to_json().to_string()
}
#[uniffi::export]
pub fn telemetry_set_surface(surface: String, version: String) {
t().set_product(&surface, &version);
}
#[uniffi::export]
pub fn telemetry_notice_text() -> String {
cua_telemetry::notice::TEXT.to_string()
}
#[uniffi::export]
pub fn telemetry_acknowledge_notice() {
t().acknowledge_notice();
}
#[uniffi::export]
pub fn telemetry_use_app_notice() {
t().set_notice_mode(cua_telemetry::NoticeMode::External);
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct TelemetryLinkNotice {
pub title: String,
pub body: String,
pub ok_label: String,
pub turn_off_label: String,
pub details_label: String,
pub docs_url: String,
}
#[uniffi::export]
pub fn telemetry_link_notice() -> TelemetryLinkNotice {
use cua_telemetry::link;
TelemetryLinkNotice {
title: link::NOTICE_TITLE.into(),
body: link::NOTICE_BODY.into(),
ok_label: link::NOTICE_OK.into(),
turn_off_label: link::NOTICE_TURN_OFF.into(),
details_label: link::NOTICE_DETAILS.into(),
docs_url: cua_telemetry::notice::DOCS_URL.into(),
}
}
#[uniffi::export]
pub fn telemetry_link_notice_due() -> bool {
t().link_notice_due()
}
#[uniffi::export]
pub fn telemetry_acknowledge_link_notice() {
t().acknowledge_link_notice();
}
#[uniffi::export]
pub fn telemetry_link_state() -> String {
t().link_state().as_str().into()
}
#[cfg(feature = "host")]
#[uniffi::export]
pub async fn telemetry_sync_account() -> String {
let _ = crate::native::run(async {
sync_account(&cua_auth::Session::from_env()).await;
Ok(())
})
.await;
telemetry_link_state()
}
#[cfg(feature = "host")]
pub(crate) async fn sync_account(session: &cua_auth::Session) {
if !t().is_enabled() {
return;
}
let budget = Duration::from_secs(10);
let Ok(Ok(token)) = tokio::time::timeout(budget, session.access_token(false)).await else {
return;
};
let Ok(api) = cua_host::spaces_api::SpacesApiClient::current() else {
return;
};
if let Ok(Ok(me)) = tokio::time::timeout(budget, api.me(&token)).await {
t().offer_account(me.analytics_id.as_deref());
}
}
#[uniffi::export]
pub fn telemetry_record_feature(feature: String) -> bool {
events::spaces_feature_used(&feature).is_some_and(|e| t().capture(e) == Captured::Queued)
}
#[uniffi::export]
pub fn telemetry_record_onboarding_step(step: String, ok: bool) -> bool {
t().capture_step(&step, if ok { Outcome::Ok } else { Outcome::Error }) == Captured::Queued
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct StreamStatsReport {
pub transport: String,
pub codec: String,
pub hw_decode: Option<bool>,
pub avg_fps: f64,
pub p95_latency_ms: Option<f64>,
pub height: u32,
pub duration_ms: u64,
}
#[uniffi::export]
pub fn telemetry_record_stream_stats(stats: StreamStatsReport) -> bool {
let e = events::stream_stats(&events::StreamStats {
transport: &stats.transport,
codec: &stats.codec,
hw_decode: stats.hw_decode,
avg_fps: stats.avg_fps,
p95_latency_ms: stats.p95_latency_ms,
height: stats.height,
duration: Duration::from_millis(stats.duration_ms),
});
t().capture(e) == Captured::Queued
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct StreamSummary {
pub transport: &'static str,
pub codec: String,
pub hw_decode: Option<bool>,
pub frames: u64,
pub height: u32,
pub duration: Duration,
}
pub(crate) fn stream_stats_event(s: &StreamSummary) -> Option<events::Event> {
events::stream_summary(
s.transport,
&s.codec,
s.hw_decode,
s.frames,
s.height,
None,
s.duration,
)
}
pub(crate) fn record_stream_on(
t: &cua_telemetry::Telemetry,
s: &StreamSummary,
) -> Option<Captured> {
stream_stats_event(s).map(|e| t.capture(e))
}
pub(crate) fn stream_ended(s: &StreamSummary) {
let _ = record_stream_on(t(), s);
}
#[uniffi::export]
pub fn telemetry_record_bench_run(
taskset: String,
success_rate: Option<f64>,
task_count: u64,
outcome: String,
) -> bool {
let o = match outcome.as_str() {
"ok" => Outcome::Ok,
"cancelled" => Outcome::Cancelled,
_ => Outcome::Error,
};
t().capture(events::bench_run_completed(
&taskset,
success_rate,
task_count,
o,
)) == Captured::Queued
}
#[uniffi::export]
pub fn telemetry_flush(timeout_ms: u32) {
t().shutdown(Duration::from_millis(u64::from(timeout_ms)));
}
pub(crate) fn variant<T>(r: &Result<T>) -> Option<&'static str> {
r.as_ref().err().map(CuaError::variant)
}
fn create_error_kind<T>(on: &str, r: &Result<T>) -> Option<&'static str> {
let e = r.as_ref().err()?;
let local = on == "local";
let variant = match e {
CuaError::ProviderNotConfigured(_) if !local => return Some(e.variant()),
CuaError::ProviderNotConfigured(_) => "Runtime",
_ => e.variant(),
};
Some(cua_create_failure::create_failure_kind(
&e.to_string(),
"",
variant,
local,
))
}
fn outcome<T>(r: &Result<T>) -> Outcome {
if r.is_ok() {
Outcome::Ok
} else {
Outcome::Error
}
}
pub(crate) fn api<T>(api: &str, started: Instant, r: &Result<T>) {
if let Some(e) = events::api_used(api, outcome(r), variant(r), started.elapsed()) {
t().capture(e);
}
}
pub(crate) struct CreateFacts {
on: String,
kind: String,
runtime: String,
image: String,
guest_os: String,
overlays: bool,
build: bool,
started: Instant,
}
impl CreateFacts {
pub(crate) fn of(o: &super::SandboxCreateOptions) -> Self {
let (on, kind, runtime) = match o.placement() {
Ok(p) => (
p.on.location().to_string(),
p.kind.as_str().to_string(),
p.runtime.to_string(),
),
Err(_) => (
o.on.clone().unwrap_or_default(),
o.kind.clone().unwrap_or_default(),
o.runtime.clone().unwrap_or_default(),
),
};
let image = cua_image::canonical::alias(&o.image).unwrap_or_else(|| o.image.clone());
let guest_os = o.os.clone().unwrap_or_else(|| {
let i = o.image.to_ascii_lowercase();
["linux", "windows", "macos", "android"]
.into_iter()
.find(|os| i.contains(os))
.unwrap_or("")
.to_string()
});
Self {
on,
kind,
runtime,
image,
guest_os,
overlays: !o.overlays.is_empty(),
build: o.build.is_some(),
started: Instant::now(),
}
}
pub(crate) fn is_local(&self) -> bool {
self.on == "local"
}
pub(crate) fn record<T>(self, r: &Result<T>) {
let e = events::sandbox_created(
&events::SandboxCreate {
on: &self.on,
kind: &self.kind,
runtime: &self.runtime,
image: &self.image,
guest_os: &self.guest_os,
with_overlays: self.overlays,
with_build: self.build,
},
outcome(r),
create_error_kind(&self.on, r),
self.started.elapsed(),
);
t().capture(e);
if r.is_ok() && self.on != "direct" {
t().capture_step("first_sandbox_created", Outcome::Ok);
}
}
}
pub(crate) fn sandbox_deleted<T>(location: &str, r: &Result<T>) {
t().capture(events::sandbox_deleted(location, outcome(r), variant(r)));
}
pub(crate) fn teleport<T>(
app: &str,
capability: &str,
move_kind: &str,
started: Instant,
r: &Result<T>,
items: u64,
) {
let product = t().product();
let info = events::TeleportInfo::new(
app,
capability,
move_kind,
events::CallerKind::self_reported(product),
events::CallerKindSource::SelfReported,
"sdk",
);
t().capture(events::teleport_attempted(&info));
let o = match r {
Ok(_) => events::TeleportOutcome::Ok,
Err(e) => teleport_outcome(e),
};
t().capture(events::teleport_completed(
&info,
o,
started.elapsed(),
items,
));
if r.is_ok() {
t().capture_step("first_teleport", Outcome::Ok);
}
}
pub(crate) fn teleport_outcome(e: &CuaError) -> events::TeleportOutcome {
use events::TeleportOutcome as T;
match e {
CuaError::TeleportRefused(_) => T::ConsentDenied,
CuaError::HostCapabilityMissing(m) if requires_cua_app(m) => T::RequiresCuaApp,
CuaError::PermissionDenied(_) | CuaError::Unauthenticated(_) => T::Forbidden,
CuaError::NotFound(_) => T::NotFound,
CuaError::InvalidArgument(_) => T::Invalid,
_ => T::Error,
}
}
pub(crate) fn requires_cua_app(message: &str) -> bool {
message.contains(cua_keyvault_links::INSTALL_URL)
|| message.contains(cua_keyvault_links::OPEN_URL)
}
mod cua_keyvault_links {
pub const INSTALL_URL: &str = "https://cua.ai/download";
pub const OPEN_URL: &str = "cua://keyvault";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_error_kind_splits_local_runtime_failures() {
let err = |e: CuaError| -> Result<()> { Err(e) };
let kind = |on: &str, e: CuaError| {
create_error_kind(on, &err(e)).map(cua_telemetry::events::error_kind)
};
let no_engine = "local runtime: [local] a container engine is not available: no socket";
assert_eq!(
kind("local", CuaError::Runtime(no_engine.into())),
Some("runtime_missing")
);
assert_eq!(
kind("local", CuaError::ProviderNotConfigured(no_engine.into())),
Some("runtime_missing")
);
assert_eq!(
kind(
"local",
CuaError::Runtime("permission denied on unix:///x: add".into())
),
Some("runtime_permission")
);
assert_eq!(
kind("local", CuaError::Runtime("boom".into())),
Some("runtime")
);
assert_eq!(
kind(
"cloud",
CuaError::ProviderNotConfigured("x is not available: y".into())
),
Some("provider_not_configured")
);
assert_eq!(
kind(
"local",
CuaError::Runtime("container engine: pull ghcr.io/x/y:1: manifest unknown".into())
),
Some("image_pull_not_found")
);
assert_eq!(
kind(
"local",
CuaError::Runtime("Docker is not running: start Docker Desktop".into())
),
Some("runtime_not_running")
);
assert_eq!(
kind(
"cloud",
CuaError::Runtime("sandbox quota exceeded for this account".into())
),
Some("quota_exceeded")
);
assert_eq!(create_error_kind::<()>("local", &Ok(())), None);
}
#[test]
fn requires_cua_app_maps_to_its_own_outcome() {
let m = "Teleport requires the Cua app, which keeps your sessions in its Keyvault and asks you before anything moves. Install it from https://cua.ai/download.";
assert_eq!(
teleport_outcome(&CuaError::HostCapabilityMissing(m.into())),
events::TeleportOutcome::RequiresCuaApp
);
assert_eq!(
teleport_outcome(&CuaError::HostCapabilityMissing("no display".into())),
events::TeleportOutcome::Error
);
assert_eq!(
teleport_outcome(&CuaError::TeleportRefused("no".into())),
events::TeleportOutcome::ConsentDenied
);
}
fn summary() -> StreamSummary {
StreamSummary {
transport: "websocket",
codec: "h264".into(),
hw_decode: Some(true),
frames: 900,
height: 1080,
duration: Duration::from_secs(30),
}
}
fn client(
home: &std::path::Path,
env: &[(&str, &str)],
) -> (
cua_telemetry::Telemetry,
std::sync::Arc<cua_telemetry::sink::MemorySink>,
) {
let m: std::collections::HashMap<String, String> = env
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let sink = std::sync::Arc::new(cua_telemetry::sink::MemorySink::new());
let t = cua_telemetry::Telemetry::builder()
.env(move |k| m.get(k).cloned())
.home(home)
.sink(sink.clone())
.product("sdk_rust", "1.2.3")
.foreground()
.build();
t.acknowledge_notice();
(t, sink)
}
#[test]
fn a_finished_stream_sends_only_bucketed_schema_properties() {
let home = tempfile::tempdir().unwrap();
let (t, sink) = client(
home.path(),
&[
("CUA_TELEMETRY", "1"),
("CUA_TELEMETRY_FORBID_NETWORK", "1"),
],
);
let queued = (0..64).any(|_| record_stream_on(&t, &summary()) == Some(Captured::Queued));
assert!(queued, "never queued in 64 tries");
t.flush(Duration::from_secs(1));
let events = sink.events();
let e = events
.iter()
.find(|e| e["event"] == "cua_stream_stats")
.expect("a cua_stream_stats event");
let props = e["properties"].as_object().unwrap();
let spec = cua_telemetry::schema::spec("cua_stream_stats").unwrap();
let own: Vec<&str> = spec.props.iter().map(|p| p.name).collect();
for k in &own {
assert!(props.contains_key(*k), "missing {k}");
}
let preview = t.preview(&stream_stats_event(&summary()).unwrap()).unwrap();
let allowed: std::collections::BTreeSet<&String> =
preview["properties"].as_object().unwrap().keys().collect();
for k in props.keys() {
assert!(allowed.contains(k), "unexpected property {k}");
}
assert_eq!(props["transport"], "websocket");
assert_eq!(props["codec"], "h264");
assert_eq!(props["decode"], "hw");
assert_eq!(props["fps_bucket"], "30_49");
assert_eq!(props["latency_bucket"], "unknown");
for (k, v) in &own.iter().map(|k| (*k, &props[*k])).collect::<Vec<_>>() {
assert!(v.is_string(), "{k} is not a bucket: {v}");
}
let raw = [
serde_json::json!(900),
serde_json::json!(1080),
serde_json::json!(30),
serde_json::json!(30.0),
];
for (k, v) in props {
assert!(!raw.contains(v), "{k} carries a raw value: {v}");
if let Some(s) = v.as_str() {
assert!(!["900", "1080", "30"].contains(&s), "{k} = {s}");
}
}
}
#[test]
fn no_stream_event_without_video() {
let mut s = summary();
s.frames = 0;
assert!(stream_stats_event(&s).is_none());
let mut s = summary();
s.duration = Duration::ZERO;
assert!(stream_stats_event(&s).is_none());
}
#[test]
fn nothing_is_sent_for_a_stream_under_any_opt_out() {
let cases: &[&[(&str, &str)]] = &[
&[("DO_NOT_TRACK", "1")],
&[("DO_NOT_TRACK", "1"), ("CUA_TELEMETRY", "1")],
&[("CUA_TELEMETRY", "0")],
&[("CUA_TELEMETRY_DISABLED", "1")],
&[("CI", "true")],
];
for env in cases {
let home = tempfile::tempdir().unwrap();
let (t, sink) = client(home.path(), env);
for _ in 0..16 {
assert_eq!(
record_stream_on(&t, &summary()),
Some(Captured::Disabled),
"{env:?}"
);
}
t.flush(Duration::from_millis(200));
assert!(sink.events().is_empty(), "{env:?}");
assert!(t.show_last(10).is_empty(), "{env:?}");
}
let home = tempfile::tempdir().unwrap();
let (t, sink) = client(home.path(), &[("CUA_TELEMETRY_FORBID_NETWORK", "1")]);
t.set_enabled(false).unwrap();
t.refresh();
assert_eq!(record_stream_on(&t, &summary()), Some(Captured::Disabled));
t.flush(Duration::from_millis(200));
assert!(sink.events().is_empty());
}
#[test]
fn exports_work_while_telemetry_is_off_in_tests() {
let s = telemetry_status();
assert!(!s.enabled);
assert!(serde_json::from_str::<serde_json::Value>(&s.envelope_json).is_ok());
assert!(!telemetry_record_feature("teleport_drop".into()));
assert!(!telemetry_record_feature("/Users/alice".into()));
assert!(telemetry_schema_json().contains("cua_sandbox_created"));
}
}