diff --git a/src-tauri/crates/berd-call/src/cli_help.rs b/src-tauri/crates/berd-call/src/cli_help.rs index 7353214fe..4bf4a56d6 100644 --- a/src-tauri/crates/berd-call/src/cli_help.rs +++ b/src-tauri/crates/berd-call/src/cli_help.rs @@ -35,7 +35,7 @@ const START_HELP: &str = r#"Run a local voice call backed by the shared session Usage: berd-call start [--port PORT] [--stream | --codex] [--non-blocking] - [--no-menu-bar] + [--no-menu-bar] [--client terminal|buzz-plugin] --voice NAME --language BCP47 [session options] The foreground process owns the default microphone and audio output. Session @@ -50,7 +50,8 @@ Microphone mute is temporary; each new call starts unmuted. Explicit startup options override saved defaults. Preferences are stored in $XDG_CONFIG_HOME/berd-call/settings.json or $HOME/.config/berd-call/settings.json. BERD_CALL_SETTINGS_FILE selects an -alternate file. Agent identity and transcript routing are never saved."#; +alternate file. `--client` labels usage telemetry for this call only and defaults +to `terminal`; the Buzz plugin passes `buzz-plugin`. It is never saved. Agent identity and transcript routing are never saved."#; const SPEAK_HELP: &str = r#"Speak through the active local voice call. diff --git a/src-tauri/crates/berd-call/src/host_session.rs b/src-tauri/crates/berd-call/src/host_session.rs index 805697456..08dd88cbd 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -79,6 +79,7 @@ pub(crate) fn run(options: StartOptions) -> Result<(), String> { &non_blocking, &stop_requested, &status_sounds, + options.client, ) { Ok(None) => break Ok(()), // A stop recorded while the old session was shutting down wins @@ -218,6 +219,7 @@ fn run_session( non_blocking: &Arc, stop_requested: &Arc, status_sounds: &Arc>, + client: crate::telemetry::Client, ) -> Result, String> { let SessionStart { saved, @@ -269,7 +271,7 @@ fn run_session( .lock() .map(|session| session.tts.settings.clone()) { - crate::telemetry::start(&control.session_arguments, &tts); + crate::telemetry::start(&control.session_arguments, &tts, client); } let control: Arc = control; while running.load(Ordering::SeqCst) { diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 06f811c17..9a6319e8e 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -769,6 +769,7 @@ enum TranscriptDestination { } struct StartOptions { + client: telemetry::Client, saved: Option<(PathBuf, saved_settings::SavedSettings)>, port: u16, transcript: TranscriptDestination, @@ -796,6 +797,7 @@ fn validate_session_arguments(arguments: &[String]) -> Result<(), String> { fn parse_start_args(args: &[String]) -> Result { let mut port = 5222_u16; let mut port_seen = false; + let mut client = None; let mut transcript = TranscriptDestination::None; let mut menu_bar = true; let mut non_blocking = false; @@ -815,6 +817,12 @@ fn parse_start_args(args: &[String]) -> Result { TranscriptDestination::Stdout }; } + "--client" if client.is_none() => { + let value = args.get(index + 1).ok_or("--client requires a value")?; + client = Some(telemetry::Client::parse(value)?); + index += 2; + } + "--client" => return Err("--client may be provided only once".into()), "--no-menu-bar" if menu_bar => { menu_bar = false; index += 1; @@ -851,6 +859,7 @@ fn parse_start_args(args: &[String]) -> Result { ); } Ok(StartOptions { + client: client.unwrap_or_default(), saved: None, port, transcript, @@ -8507,6 +8516,35 @@ mod tests { ); } + #[test] + fn client_is_bounded_and_not_forwarded_as_a_speech_preference() { + let start = |flags: &[&str]| { + let mut all = vec!["berd-call", "start"]; + all.extend_from_slice(flags); + all.extend(["--voice", "Aaron", "--language", "en-US"]); + parse_start_args(&args(&all)) + }; + assert_eq!(start(&[]).unwrap().client, telemetry::Client::Terminal); + for (value, expected) in [ + ("terminal", telemetry::Client::Terminal), + ("buzz-plugin", telemetry::Client::BuzzPlugin), + ] { + let options = start(&["--client", value]).unwrap(); + assert_eq!(options.client, expected); + assert_eq!( + options.session_arguments, + ["session", "--voice", "Aaron", "--language", "en-US"] + ); + } + for args in [ + &["--client"][..], + &["--client", "private"], + &["--client", "buzz-plugin", "--client", "terminal"], + ] { + assert!(start(args).is_err()); + } + } + #[test] fn start_parser_keeps_only_session_options_for_the_child() { let parsed = parse_start_args(&args(&[ diff --git a/src-tauri/crates/berd-call/src/telemetry.rs b/src-tauri/crates/berd-call/src/telemetry.rs index 6013d3faa..489032a91 100644 --- a/src-tauri/crates/berd-call/src/telemetry.rs +++ b/src-tauri/crates/berd-call/src/telemetry.rs @@ -17,7 +17,7 @@ use berd_call::telemetry_store as store; use berd_call::TtsSettings; use serde_json::{json, Value}; -const SCHEMA_VERSION: &str = "berd-call-otlp-logs-v1"; +const SCHEMA_VERSION: &str = "berd-call-otlp-logs-v3"; const ENFORCED: bool = cfg!(feature = "telemetry-enforced"); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); @@ -92,9 +92,34 @@ fn command_in(dir: &Path, args: &[&str]) -> Result { Ok(json!({"enabled": consented(dir), "enforced": ENFORCED})) } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Client { + #[default] + Terminal, + BuzzPlugin, +} + +impl Client { + pub(crate) fn parse(value: &str) -> Result { + match value { + "terminal" => Ok(Self::Terminal), + "buzz-plugin" => Ok(Self::BuzzPlugin), + _ => Err("--client must be terminal or buzz-plugin".into()), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Terminal => "terminal", + Self::BuzzPlugin => "buzz-plugin", + } + } +} + /// The call's audio setup, as Berd's voice events report it. #[derive(Clone, Debug, PartialEq)] struct Context { + client: Client, input: &'static str, output: &'static str, rate: Option, @@ -105,6 +130,7 @@ impl Context { /// Reports the selected voice only from its backend's own catalog, and any /// other voice as "custom", so free-form names never leave the machine. fn new( + client: Client, session_arguments: &[String], tts: &TtsSettings, siri_voices: impl FnOnce() -> Vec, @@ -128,6 +154,7 @@ impl Context { }; let (rate, voice) = (tts.rate(), tts.voice()); Some(Self { + client, input: ["macos", "parakeet", "openai"] .into_iter() .find(|known| *known == input)?, @@ -142,6 +169,7 @@ impl Context { ("input_backend", self.input.to_string()), ("output_backend", self.output.to_string()), ("voice_mode", "chained".to_string()), + ("client", self.client.as_str().to_string()), ]; attributes.extend(self.rate.clone().map(|rate| ("tts_rate", rate))); attributes.extend(self.voice.clone().map(|voice| ("tts_voice", voice))); @@ -174,7 +202,7 @@ pub(crate) fn user_stopped() { } /// Reports the call's start once, when its first session is ready. -pub(crate) fn start(session_arguments: &[String], tts: &TtsSettings) { +pub(crate) fn start(session_arguments: &[String], tts: &TtsSettings, call_client: Client) { let (Some(build), Some(dir)) = (build(), berd_dir()) else { return; }; @@ -182,7 +210,7 @@ pub(crate) fn start(session_arguments: &[String], tts: &TtsSettings) { if call.is_some() || !consented(&dir) { return; } - let Some(context) = Context::new(session_arguments, tts, || { + let Some(context) = Context::new(call_client, session_arguments, tts, || { berd_call::siri::load_voice_catalog(None) .map(|catalog| catalog.voices.into_iter().map(|voice| voice.name).collect()) .unwrap_or_default() @@ -383,7 +411,9 @@ mod tests { } fn context(values: &[&str], tts: &TtsSettings) -> Option { - Context::new(&arguments(values), tts, || vec!["Samantha".into()]) + Context::new(Client::Terminal, &arguments(values), tts, || { + vec!["Samantha".into()] + }) } fn arguments(values: &[&str]) -> Vec { @@ -394,6 +424,32 @@ mod tests { UNIX_EPOCH + Duration::from_secs(seconds) } + #[test] + fn both_lifecycle_records_retain_the_starting_client() { + for client in [Client::Terminal, Client::BuzzPlugin] { + let context = Context { + client, + ..context(&["session"], &pocket(1.0)).unwrap() + }; + let started = record( + "berd_voice_conversation_started", + at(1), + &context.attributes(), + ); + let ended = record( + "berd_voice_conversation_ended", + at(2), + &ended_attributes(&context, Duration::from_secs(1), 0, 0, "user"), + ); + for event in [started, ended] { + assert!(event["attributes"] + .as_array() + .unwrap() + .contains(&json!({"key":"client", "value":{"stringValue":client.as_str()}}))); + } + } + } + #[test] fn context_reports_backends_and_a_valid_rate() { let parakeet = context( @@ -413,6 +469,7 @@ mod tests { ("input_backend", "parakeet".to_string()), ("output_backend", "pocket".to_string()), ("voice_mode", "chained".to_string()), + ("client", "terminal".to_string()), ("tts_rate", "1.25".to_string()), ("tts_voice", "anna".to_string()), ] @@ -486,6 +543,7 @@ mod tests { {"key": "input_backend", "value": {"stringValue": "parakeet"}}, {"key": "output_backend", "value": {"stringValue": "pocket"}}, {"key": "voice_mode", "value": {"stringValue": "chained"}}, + {"key": "client", "value": {"stringValue": "terminal"}}, {"key": "tts_rate", "value": {"stringValue": "1.25"}}, {"key": "tts_voice", "value": {"stringValue": "anna"}}, ]); @@ -690,7 +748,7 @@ mod tests { .collect::>() }; assert_eq!(header("authorization"), ["Bearer token-1"]); - assert_eq!(header("x-berd-schema-version"), ["berd-call-otlp-logs-v1"]); + assert_eq!(header("x-berd-schema-version"), ["berd-call-otlp-logs-v3"]); assert_eq!(header("content-type"), ["application/json"]); let resource = &requests[1].body["resourceLogs"][0]["resource"]["attributes"]; assert_eq!( @@ -725,7 +783,7 @@ mod tests { "clean-shutdown", ); assert_eq!( - &attributes[4..], + &attributes[5..], [ ("duration_ms", "1500".to_string()), ("user_utterance_count", "0".to_string()),