Add per-command CLI telemetry (#791)

* Add per-command telemetry via cliEventTrack mutation

Sends command name, subcommand, duration, success/error, OS, arch,
CLI version, and CI flag to the Railway API after each invocation.
Silently skipped when unauthenticated, on network failure, or when
the user opts out via DO_NOT_TRACK=1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Migrate telemetry to typed graphql_client mutation

Pull updated schema with cliEventTrack mutation and replace the raw
JSON POST with typed codegen via graphql_client, matching the rest
of the codebase. Also adds DNS_RECORD_TYPE_TXT from the schema update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add one-time telemetry notice on first run

Shows a notice to stderr informing users about CLI usage data
collection, with DO_NOT_TRACK=1 opt-out. Persisted to
~/.railway/notices.json so it only displays once. Suppressed
in CI/non-TTY and when telemetry is already disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Support RAILWAY_NO_TELEMETRY env var for opting out of telemetry

Adds RAILWAY_NO_TELEMETRY=1 as a Railway-specific alternative to
DO_NOT_TRACK=1 for disabling CLI telemetry. Updates the notice
text to mention both variables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix telemetry docs link to /cli/telemetry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix cargo fmt --all formatting in telemetry notice

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Faraz Patankar 2026-02-12 21:03:59 +04:00 committed by GitHub
parent 85f6e0061b
commit 330ac9ed67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2956 additions and 18 deletions

View file

@ -9,6 +9,14 @@ use crate::controllers;
pub type DateTime = DateTimeType<Utc>;
type EnvironmentConfig = controllers::config::EnvironmentConfig;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
query_path = "src/gql/mutations/strings/CliEventTrack.graphql",
response_derives = "Debug, Serialize, Clone"
)]
pub struct CliEventTrack;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
@ -259,6 +267,7 @@ impl std::fmt::Display for custom_domain_create::DNSRecordType {
Self::DNS_RECORD_TYPE_CNAME => write!(f, "CNAME"),
Self::DNS_RECORD_TYPE_A => write!(f, "A"),
Self::DNS_RECORD_TYPE_NS => write!(f, "NS"),
Self::DNS_RECORD_TYPE_TXT => write!(f, "TXT"),
Self::DNS_RECORD_TYPE_UNSPECIFIED => write!(f, "UNSPECIFIED"),
Self::UNRECOGNIZED => write!(f, "UNRECOGNIZED"),
Self::Other(s) => write!(f, "{s}"),

View file

@ -0,0 +1,3 @@
mutation CliEventTrack($input: CliEventTrackInput!) {
cliEventTrack(input: $input)
}

2823
src/gql/schema.json generated

File diff suppressed because it is too large Load diff

View file

@ -49,9 +49,27 @@ macro_rules! commands {
match matches.subcommand() {
$(
Some((stringify!([<$module:snake>]), sub_matches)) => {
let args = <$module::Args as ::clap::FromArgMatches>::from_arg_matches(sub_matches)
.map_err(anyhow::Error::from)?;
$module::command(args).await?;
let subcommand_name = sub_matches.subcommand_name().map(|s| s.to_string());
let args = <$module::Args as ::clap::FromArgMatches>::from_arg_matches(sub_matches)
.map_err(anyhow::Error::from)?;
let start = ::std::time::Instant::now();
let result = $module::command(args).await;
let duration = start.elapsed();
$crate::telemetry::send($crate::telemetry::CliTrackEvent {
command: stringify!([<$module:snake>]).to_string(),
sub_command: subcommand_name,
success: result.is_ok(),
error_message: result.as_ref().err().map(|e| {
let msg = format!("{e}");
if msg.len() > 256 { msg[..256].to_string() } else { msg }
}),
duration_ms: duration.as_millis() as u64,
cli_version: env!("CARGO_PKG_VERSION"),
os: ::std::env::consts::OS,
arch: ::std::env::consts::ARCH,
is_ci: $crate::config::Configs::env_is_ci(),
}).await;
result?;
},
)*
_ => {

View file

@ -21,6 +21,7 @@ mod workspace;
#[macro_use]
mod macros;
mod telemetry;
// Generates the commands based on the modules in the commands directory
// Specify the modules you want to include in the commands_enum! macro
@ -99,6 +100,8 @@ async fn handle_update_task(
async fn main() -> Result<()> {
let args = build_args().try_get_matches();
let check_updates_handle = if std::io::stdout().is_terminal() {
telemetry::show_notice_if_needed();
let update = UpdateCheck::read().unwrap_or_default();
if let Some(latest_version) = update.latest_version {

112
src/telemetry.rs Normal file
View file

@ -0,0 +1,112 @@
use colored::Colorize;
use crate::client::{GQLClient, post_graphql};
use crate::config::Configs;
use crate::gql::mutations::{self, cli_event_track};
#[derive(serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct Notices {
telemetry_notice_shown: bool,
}
impl Notices {
fn path() -> Option<std::path::PathBuf> {
dirs::home_dir().map(|h| h.join(".railway/notices.json"))
}
fn read() -> Self {
Self::path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write(&self) {
if let Some(path) = Self::path() {
let _ = serde_json::to_string(self)
.ok()
.map(|contents| std::fs::write(path, contents));
}
}
}
pub fn show_notice_if_needed() {
if is_telemetry_disabled() {
return;
}
let notices = Notices::read();
if notices.telemetry_notice_shown {
return;
}
eprintln!(
"{}\nYou can opt out by setting RAILWAY_NO_TELEMETRY=1 or DO_NOT_TRACK=1 in your environment.\n{}",
"Railway now collects CLI usage data to improve the developer experience.".bold(),
format!("Learn more: {}", "https://docs.railway.com/cli/telemetry").dimmed(),
);
Notices {
telemetry_notice_shown: true,
}
.write();
}
pub struct CliTrackEvent {
pub command: String,
pub sub_command: Option<String>,
pub duration_ms: u64,
pub success: bool,
pub error_message: Option<String>,
pub os: &'static str,
pub arch: &'static str,
pub cli_version: &'static str,
pub is_ci: bool,
}
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 is_telemetry_disabled() -> bool {
env_var_is_truthy("DO_NOT_TRACK") || env_var_is_truthy("RAILWAY_NO_TELEMETRY")
}
pub async fn send(event: CliTrackEvent) {
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 vars = cli_event_track::Variables {
input: cli_event_track::CliEventTrackInput {
command: event.command,
sub_command: event.sub_command,
duration_ms: event.duration_ms as i64,
success: event.success,
error_message: event.error_message,
os: event.os.to_string(),
arch: event.arch.to_string(),
cli_version: event.cli_version.to_string(),
is_ci: event.is_ci,
},
};
let _ = tokio::time::timeout(
std::time::Duration::from_secs(3),
post_graphql::<mutations::CliEventTrack, _>(&client, configs.get_backboard(), vars),
)
.await;
}