diff --git a/install.sh b/install.sh index f51930d0e..8b0fa5102 100755 --- a/install.sh +++ b/install.sh @@ -544,6 +544,11 @@ run_agent_setup() { local railway_bin="$1" local yes="" + if [ -z "${RAILWAY_INSTALL_REQUEST_ID-}" ]; then + RAILWAY_INSTALL_REQUEST_ID="install_$(od -vAn -N16 -tx1 < /dev/urandom | tr -d ' \n')" + export RAILWAY_INSTALL_REQUEST_ID + fi + if [ -n "${FORCE-}" ] || [ ! -t 0 ]; then yes="-y" fi diff --git a/src/commands/login.rs b/src/commands/login.rs index e3be8b67c..a01709755 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -49,6 +49,7 @@ pub async fn command(args: Args) -> Result<()> { if let Ok(client) = GQLClient::new_authorized(&configs) { match get_user(&client, &configs).await { Ok(user) => { + let _ = configs.save_user_id(&user.id); println!("{} found", token_name.bold()); print_user(user); return Ok(()); @@ -85,6 +86,10 @@ pub async fn command(args: Args) -> Result<()> { .await? .me; + if let Err(e) = configs.save_user_id(&me.id) { + eprintln!("{}: {e}", "Warning: failed to persist user id".yellow()); + } + if let Some(name) = me.name { println!("Logged in as {} ({})", name.bold(), me.email); } else { diff --git a/src/commands/setup.rs b/src/commands/setup.rs index b75ace1f4..652ae29db 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -10,6 +10,7 @@ use crate::{ consts::{RAILWAY_API_TOKEN_ENV, RAILWAY_TOKEN_ENV}, controllers::user::get_user, macros::is_stdout_terminal, + telemetry::{self, SetupAgentPhase, SetupAgentTrackEvent}, }; const DOCS_URL: &str = "https://docs.railway.com/ai"; @@ -102,6 +103,44 @@ fn pick_mcp_choice(remote_flag: bool, non_interactive: bool) -> Result Result<()> { + telemetry::send_setup_agent(SetupAgentTrackEvent { + phase: SetupAgentPhase::Start, + success: None, + error_message: None, + configured_clients: None, + }) + .await; + + match agent_setup_inner(args).await { + Ok(configured_clients) => { + telemetry::send_setup_agent(SetupAgentTrackEvent { + phase: SetupAgentPhase::Finish, + success: Some(true), + error_message: None, + configured_clients: Some(configured_clients), + }) + .await; + Ok(()) + } + Err(err) => { + let message = err.to_string(); + telemetry::send_setup_agent(SetupAgentTrackEvent { + phase: SetupAgentPhase::Finish, + success: Some(false), + error_message: Some(if message.len() > 256 { + message[..256].to_string() + } else { + message + }), + configured_clients: None, + }) + .await; + Err(err) + } + } +} + +async fn agent_setup_inner(args: AgentArgs) -> Result> { let home = dirs::home_dir().context("could not determine home directory")?; // Treat the run as non-interactive if the user passed -y, OR if stdout // isn't a TTY (piped, CI, agent-driven). Matches the convention used by @@ -145,7 +184,7 @@ async fn agent_setup(args: AgentArgs) -> Result<()> { if picked.is_empty() { println!("{}", "No editors selected. Nothing to do.".yellow()); - return Ok(()); + return Ok(Vec::new()); } picked.iter().map(|c| c.slug.to_string()).collect() }; @@ -155,9 +194,11 @@ async fn agent_setup(args: AgentArgs) -> Result<()> { "{}", "No editors detected. Re-run interactively to pick, or rerun in a TTY.".yellow() ); - return Ok(()); + return Ok(Vec::new()); } + let configured_clients = selected_slugs.clone(); + // Step 1: skills install let missing_skills: Vec = selected_slugs .iter() @@ -208,7 +249,7 @@ async fn agent_setup(args: AgentArgs) -> Result<()> { eprintln!("{}: {e}", "Warning: failed to record agent setup".yellow()); } - Ok(()) + Ok(configured_clients) } async fn install_missing_mcp( diff --git a/src/config.rs b/src/config.rs index 548c9b2b4..de9e72f22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,7 @@ impl LinkedProject { #[serde_with::skip_serializing_none] #[serde(rename_all = "camelCase")] pub struct RailwayUser { + pub id: Option, pub token: Option, pub access_token: Option, pub refresh_token: Option, @@ -216,6 +217,12 @@ impl Configs { self.write() } + pub fn save_user_id(&mut self, id: &str) -> Result<()> { + anyhow::ensure!(!id.is_empty(), "user id cannot be empty"); + self.root_config.user.id = Some(id.to_string()); + self.write() + } + pub fn get_environment_id() -> Environment { match std::env::var("RAILWAY_ENV") .map(|env| env.to_lowercase()) diff --git a/src/consts.rs b/src/consts.rs index 1d2affc69..9b1c96796 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -7,6 +7,9 @@ pub const RAILWAY_API_TOKEN_ENV: &str = "RAILWAY_API_TOKEN"; pub const RAILWAY_PROJECT_ID_ENV: &str = "RAILWAY_PROJECT_ID"; pub const RAILWAY_ENVIRONMENT_ID_ENV: &str = "RAILWAY_ENVIRONMENT_ID"; pub const RAILWAY_SERVICE_ID_ENV: &str = "RAILWAY_SERVICE_ID"; +pub const RAILWAY_CALLER_ENV: &str = "RAILWAY_CALLER"; +pub const RAILWAY_AGENT_SESSION_ENV: &str = "RAILWAY_AGENT_SESSION"; +pub const RAILWAY_INSTALL_REQUEST_ID_ENV: &str = "RAILWAY_INSTALL_REQUEST_ID"; pub const RAILWAY_STAGE_UPDATE_ENV: &str = "_RAILWAY_STAGE_UPDATE"; pub const TICK_STRING: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "; diff --git a/src/gql/mutations/mod.rs b/src/gql/mutations/mod.rs index b8f810b93..59cd31219 100644 --- a/src/gql/mutations/mod.rs +++ b/src/gql/mutations/mod.rs @@ -15,6 +15,7 @@ type EnvironmentConfig = controllers::config::EnvironmentConfig; query_path = "src/gql/mutations/strings/CliEventTrack.graphql", response_derives = "Debug, Serialize, Clone" )] +#[allow(dead_code)] pub struct CliEventTrack; #[derive(GraphQLQuery)] diff --git a/src/gql/queries/strings/UserMeta.graphql b/src/gql/queries/strings/UserMeta.graphql index 324c31091..22541b467 100644 --- a/src/gql/queries/strings/UserMeta.graphql +++ b/src/gql/queries/strings/UserMeta.graphql @@ -1,5 +1,6 @@ query UserMeta { me { + id name email } diff --git a/src/telemetry.rs b/src/telemetry.rs index c16e662ea..54dbae8c9 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -1,8 +1,16 @@ +use std::{io::IsTerminal, sync::OnceLock}; + use anyhow::Context; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use rand::RngCore; +use serde::Serialize; +use serde_json::{Value, json}; -use crate::client::{GQLClient, post_graphql}; +use crate::client::GQLClient; use crate::config::Configs; -use crate::gql::mutations::{self, cli_event_track}; +use crate::consts::{ + RAILWAY_AGENT_SESSION_ENV, RAILWAY_CALLER_ENV, RAILWAY_INSTALL_REQUEST_ID_ENV, +}; pub struct CliTrackEvent { pub command: String, @@ -16,12 +24,299 @@ pub struct CliTrackEvent { pub is_ci: bool, } +pub struct SetupAgentTrackEvent { + pub phase: SetupAgentPhase, + pub success: Option, + pub error_message: Option, + pub configured_clients: Option>, +} + +pub enum SetupAgentPhase { + Start, + Finish, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CliEventTrackInput { + command: String, + sub_command: Option, + duration_ms: i64, + success: bool, + error_message: Option, + os: String, + arch: String, + cli_version: String, + is_ci: bool, + session_id: String, + caller: String, + agent_session_id: Option, + install_request_id: Option, + project_id: Option, + environment_id: Option, + service_id: Option, + error_class: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LegacyCliEventTrackInput { + command: String, + sub_command: Option, + duration_ms: i64, + success: bool, + error_message: Option, + os: String, + arch: String, + cli_version: String, + is_ci: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SetupAgentEventTrackInput { + phase: &'static str, + success: Option, + error_message: Option, + configured_clients: Option>, + session_id: String, + caller: String, + agent_session_id: Option, + install_request_id: Option, + cli_version: String, + os: String, + arch: String, + is_ci: bool, +} + +#[derive(Clone)] +struct TelemetryContext { + session_id: String, + caller: String, + agent_session_id: Option, + install_request_id: Option, + project_id: Option, + environment_id: Option, + service_id: Option, +} + fn env_var_is_truthy(name: &str) -> bool { std::env::var(name) .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false) } +fn safe_telemetry_value(value: &str) -> Option { + if value.is_empty() || value.len() > 256 { + return None; + } + + if value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'@' | b'/' | b'-')) + { + Some(value.to_string()) + } else { + None + } +} + +fn safe_env(name: &str) -> Option { + std::env::var(name) + .ok() + .and_then(|value| safe_telemetry_value(value.trim())) +} + +fn session_id() -> String { + static SESSION_ID: OnceLock = OnceLock::new(); + SESSION_ID + .get_or_init(|| { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("cli_{}", URL_SAFE_NO_PAD.encode(bytes)) + }) + .clone() +} + +fn known_agent_from_env() -> Option<&'static str> { + const ENVS: &[(&str, &str)] = &[ + ("OPENCODE", "opencode"), + ("OPENCODE_SESSION_ID", "opencode"), + ("CLAUDECODE", "claude_code"), + ("CLAUDE_CODE", "claude_code"), + ("CLAUDECODE_SESSION_ID", "claude_code"), + ("CURSOR_TRACE_ID", "cursor"), + ("CURSOR_AGENT", "cursor"), + ("CODEX_SANDBOX", "codex"), + ("OPENAI_CODEX", "codex"), + ]; + + ENVS.iter() + .find_map(|(name, caller)| std::env::var(name).ok().map(|_| *caller)) +} + +fn caller_from_process_name(name: &str) -> Option<&'static str> { + let name = name.to_ascii_lowercase(); + if name.contains("opencode") { + Some("opencode") + } else if name.contains("claude") { + Some("claude_code") + } else if name.contains("cursor") { + Some("cursor") + } else if name.contains("codex") { + Some("codex") + } else if name.contains("windsurf") { + Some("windsurf") + } else { + None + } +} + +#[cfg(unix)] +fn ps_field(pid: u32, field: &str) -> Option { + let pid = pid.to_string(); + let output = std::process::Command::new("ps") + .args(["-o", field, "-p", &pid]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { None } else { Some(value) } +} + +#[cfg(unix)] +fn detect_process_caller() -> Option<&'static str> { + let mut pid = std::process::id(); + for _ in 0..8 { + if let Some(comm) = ps_field(pid, "comm=") { + if let Some(caller) = caller_from_process_name(&comm) { + return Some(caller); + } + } + + let parent = ps_field(pid, "ppid=")?.trim().parse::().ok()?; + if parent == 0 || parent == pid { + break; + } + pid = parent; + } + None +} + +#[cfg(not(unix))] +fn detect_process_caller() -> Option<&'static str> { + None +} + +fn detect_caller() -> String { + static CALLER: OnceLock = OnceLock::new(); + CALLER + .get_or_init(|| { + safe_env(RAILWAY_CALLER_ENV) + .or_else(|| known_agent_from_env().map(str::to_string)) + .or_else(|| detect_process_caller().map(str::to_string)) + .unwrap_or_else(|| { + if Configs::env_is_ci() { + "ci".to_string() + } else if !std::io::stdout().is_terminal() { + "agent_subprocess".to_string() + } else { + "tty".to_string() + } + }) + }) + .clone() +} + +fn is_agent_caller(caller: &str) -> bool { + !matches!(caller, "tty" | "ci") +} + +fn error_class(message: Option<&str>) -> String { + let Some(message) = message else { + return "UNKNOWN".to_string(); + }; + + let message = message.to_ascii_lowercase(); + let class = if message.contains("not authorized") + || message.contains("unauthorized") + || message.contains("forbidden") + || message.contains("access denied") + { + "AUTHORIZATION" + } else if message.contains("login") + || message.contains("authenticated") + || message.contains("authentication") + || message.contains("token") + { + "AUTHENTICATION" + } else if message.contains("not found") || message.contains("no linked project") { + "NOT_FOUND" + } else if message.contains("invalid") + || message.contains("required") + || message.contains("must") + { + "VALIDATION" + } else if message.contains("rate limit") || message.contains("ratelimit") { + "RATE_LIMITED" + } else if message.contains("timeout") || message.contains("timed out") { + "TIMEOUT" + } else { + "UNKNOWN" + }; + + class.to_string() +} + +impl TelemetryContext { + fn current(configs: &Configs) -> Self { + let session_id = session_id(); + let caller = detect_caller(); + let linked_project = configs.get_local_linked_project().ok(); + let agent_session_id = safe_env(RAILWAY_AGENT_SESSION_ENV).or_else(|| { + if is_agent_caller(&caller) { + Some(session_id.clone()) + } else { + None + } + }); + + Self { + session_id, + caller, + agent_session_id, + install_request_id: safe_env(RAILWAY_INSTALL_REQUEST_ID_ENV), + project_id: Configs::get_railway_project_id() + .and_then(|id| safe_telemetry_value(&id)) + .or_else(|| { + linked_project + .as_ref() + .and_then(|p| safe_telemetry_value(&p.project)) + }), + environment_id: Configs::get_railway_environment_id() + .and_then(|id| safe_telemetry_value(&id)) + .or_else(|| { + linked_project + .as_ref() + .and_then(|p| p.environment.as_deref()) + .and_then(safe_telemetry_value) + }), + service_id: Configs::get_railway_service_id() + .and_then(|id| safe_telemetry_value(&id)) + .or_else(|| { + linked_project + .as_ref() + .and_then(|p| p.service.as_deref()) + .and_then(safe_telemetry_value) + }), + } + } +} + #[derive(serde::Serialize, serde::Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct Preferences { @@ -68,6 +363,21 @@ fn is_telemetry_disabled() -> bool { is_telemetry_disabled_by_env() || Preferences::read().telemetry_disabled } +async fn post_telemetry_body(client: &reqwest::Client, url: String, body: Value) -> bool { + let result = tokio::time::timeout(std::time::Duration::from_secs(3), async move { + let response = client.post(url).json(&body).send().await?; + if !response.status().is_success() { + return Ok::(false); + } + + let response_body: Value = response.json().await?; + Ok(response_body.get("errors").is_none()) + }) + .await; + + matches!(result, Ok(Ok(true))) +} + pub async fn send(event: CliTrackEvent) { if is_telemetry_disabled() { return; @@ -83,8 +393,39 @@ pub async fn send(event: CliTrackEvent) { Err(_) => return, }; - let vars = cli_event_track::Variables { - input: cli_event_track::CliEventTrackInput { + let context = TelemetryContext::current(&configs); + let error_class = if event.success { + None + } else { + Some(error_class(event.error_message.as_deref())) + }; + let input = CliEventTrackInput { + command: event.command.clone(), + sub_command: event.sub_command.clone(), + duration_ms: event.duration_ms as i64, + success: event.success, + error_message: event.error_message.clone(), + os: event.os.to_string(), + arch: event.arch.to_string(), + cli_version: event.cli_version.to_string(), + is_ci: event.is_ci, + session_id: context.session_id, + caller: context.caller, + agent_session_id: context.agent_session_id, + install_request_id: context.install_request_id, + project_id: context.project_id, + environment_id: context.environment_id, + service_id: context.service_id, + error_class, + }; + + let body = json!({ + "query": "mutation CliEventTrack($input: CliEventTrackInput!) { cliEventTrack(input: $input) }", + "variables": { "input": input }, + }); + + if !post_telemetry_body(&client, configs.get_backboard(), body).await { + let legacy_input = LegacyCliEventTrackInput { command: event.command, sub_command: event.sub_command, duration_ms: event.duration_ms as i64, @@ -94,12 +435,54 @@ pub async fn send(event: CliTrackEvent) { arch: event.arch.to_string(), cli_version: event.cli_version.to_string(), is_ci: event.is_ci, + }; + let legacy_body = json!({ + "query": "mutation CliEventTrack($input: CliEventTrackInput!) { cliEventTrack(input: $input) }", + "variables": { "input": legacy_input }, + }); + + let _ = post_telemetry_body(&client, configs.get_backboard(), legacy_body).await; + } +} + +pub async fn send_setup_agent(event: SetupAgentTrackEvent) { + if is_telemetry_disabled() { + return; + } + + let configs = match Configs::new() { + Ok(c) => c, + Err(_) => return, + }; + + let client = match GQLClient::new_authorized(&configs) { + Ok(c) => c, + Err(_) => return, + }; + + let context = TelemetryContext::current(&configs); + let input = SetupAgentEventTrackInput { + phase: match event.phase { + SetupAgentPhase::Start => "start", + SetupAgentPhase::Finish => "finish", }, + success: event.success, + error_message: event.error_message, + configured_clients: event.configured_clients, + session_id: context.session_id, + caller: context.caller, + agent_session_id: context.agent_session_id, + install_request_id: context.install_request_id, + cli_version: env!("CARGO_PKG_VERSION").to_string(), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + is_ci: Configs::env_is_ci(), }; - let _ = tokio::time::timeout( - std::time::Duration::from_secs(3), - post_graphql::(&client, configs.get_backboard(), vars), - ) - .await; + let body = json!({ + "query": "mutation SetupAgentEventTrack($input: SetupAgentEventTrackInput!) { setupAgentEventTrack(input: $input) }", + "variables": { "input": input }, + }); + + let _ = post_telemetry_body(&client, configs.get_backboard(), body).await; }