diff --git a/.github/workflows/cargo-release.yml b/.github/workflows/cargo-release.yml index 3ce154664..46b88e513 100644 --- a/.github/workflows/cargo-release.yml +++ b/.github/workflows/cargo-release.yml @@ -77,6 +77,8 @@ jobs: - name: Build run: | cargo build --release -p codemod --target ${{ matrix.target }} + env: + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} - name: Chmod binary run: | diff --git a/Cargo.lock b/Cargo.lock index fec43e79f..52022a965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,7 @@ version = "1.0.0-rc.12" dependencies = [ "anyhow", "ast-grep-language", + "async-trait", "base64 0.22.1", "butterflow-core", "butterflow-models", @@ -770,6 +771,7 @@ dependencies = [ "chrono", "clap", "codemod-sandbox", + "codemod-telemetry", "console", "dirs", "env_logger", @@ -784,6 +786,7 @@ dependencies = [ "oauth2", "open", "percent-encoding", + "posthog-rs", "rand 0.8.5", "regex", "reqwest 0.11.27", @@ -845,6 +848,15 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "codemod-telemetry" +version = "1.0.0-rc.12" +dependencies = [ + "async-trait", + "posthog-rs", + "serde", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -3488,6 +3500,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "posthog-rs" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610d236077c94c96194b0d2b40989bda4bf8faf60e15a0da6ef8ffd4c78f6c1c" +dependencies = [ + "chrono", + "derive_builder", + "reqwest 0.11.27", + "semver", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 9ab8188ad..d7a40e3cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/state", "crates/codemod-sandbox", "crates/codemod-sandbox/build", + "crates/telemetry", "xtask" ] resolver = "2" @@ -29,6 +30,7 @@ butterflow-state = { path = "crates/state" } butterflow-runners = { path = "crates/runners" } butterflow-scheduler = { path = "crates/scheduler" } codemod-sandbox = { path = "crates/codemod-sandbox" } +codemod-telemetry = { path = "crates/telemetry" } anyhow = "1.0" ast-grep-language = "0.38.6" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 91bd4602b..1d4ec6e1a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -15,6 +15,7 @@ butterflow-core = { workspace = true } butterflow-state = { workspace = true } butterflow-runners = { workspace = true } codemod-sandbox = { workspace = true } +codemod-telemetry = { workspace = true } tokio = { workspace = true } clap = { version = "4.5", features = ["derive"] } flate2 = { workspace = true } @@ -57,7 +58,8 @@ libtest-mimic = "0.8" similar = "2.0" ast-grep-language.workspace = true tabled = "0.20.0" - +posthog-rs = "0.3.7" +async-trait.workspace = true [features] default = [] diff --git a/crates/cli/src/commands/publish.rs b/crates/cli/src/commands/publish.rs index 124f2285a..771e3ceaa 100644 --- a/crates/cli/src/commands/publish.rs +++ b/crates/cli/src/commands/publish.rs @@ -4,12 +4,14 @@ use log::{debug, info, warn}; use reqwest; use serde::{Deserialize, Serialize}; use serde_yaml; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; use walkdir::WalkDir; use crate::auth::TokenStorage; +use codemod_telemetry::send_event::{BaseEvent, TelemetrySender}; #[derive(Args, Debug)] pub struct Command { @@ -117,7 +119,7 @@ struct PublishedPackage { published_at: String, } -pub async fn handler(args: &Command) -> Result<()> { +pub async fn handler(args: &Command, telemetry: &dyn TelemetrySender) -> Result<()> { let package_path = args .path .as_ref() @@ -196,6 +198,22 @@ pub async fn handler(args: &Command) -> Result<()> { return Err(anyhow!("Failed to publish package")); } + let cli_version = env!("CARGO_PKG_VERSION"); + + let _ = telemetry + .send_event( + BaseEvent { + kind: "codemodPublished".to_string(), + properties: HashMap::from([ + ("codemodName".to_string(), manifest.name.clone()), + ("version".to_string(), manifest.version.clone()), + ("cliVersion".to_string(), cli_version.to_string()), + ]), + }, + None, + ) + .await; + println!("✅ Package published successfully!"); println!("📦 {}", format_package_name(&response.package)); println!("🏷️ Version: {}", response.package.version); diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index b25d46750..b47cd2e99 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -2,14 +2,19 @@ use anyhow::Result; use butterflow_core::utils::get_cache_dir; use clap::Args; use log::info; +use rand::Rng; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; +use tokio::sync::Mutex; use crate::auth_provider::CliAuthProvider; use crate::dirty_git_check; use crate::workflow_runner::{run_workflow, WorkflowRunConfig}; -use butterflow_core::engine::Engine; +use butterflow_core::engine::{Engine, GLOBAL_STATS}; use butterflow_core::registry::{RegistryClient, RegistryConfig, RegistryError}; +use codemod_sandbox::sandbox::engine::ExecutionStats; +use codemod_telemetry::send_event::{BaseEvent, TelemetrySender}; #[derive(Args, Debug)] pub struct Command { @@ -42,7 +47,11 @@ pub struct Command { allow_dirty: bool, } -pub async fn handler(engine: &Engine, args: &Command) -> Result<()> { +pub async fn handler( + engine: &Engine, + args: &Command, + telemetry: &dyn TelemetrySender, +) -> Result<()> { // Create auth provider let auth_provider = CliAuthProvider::new()?; @@ -92,14 +101,60 @@ pub async fn handler(engine: &Engine, args: &Command) -> Result<()> { ); // Execute the codemod - execute_codemod( + let stats = execute_codemod( engine, &resolved_package.package_dir, &args.path, &args.args, args.dry_run, ) - .await?; + .await; + + let cli_version = env!("CARGO_PKG_VERSION"); + + if let Err(e) = stats { + let _ = telemetry + .send_event( + BaseEvent { + kind: "failedToExecuteCommand".to_string(), + properties: HashMap::from([ + ("codemodName".to_string(), args.package.clone()), + ("cliVersion".to_string(), cli_version.to_string()), + ( + "commandName".to_string(), + "codemod.executeCodemod".to_string(), + ), + ]), + }, + None, + ) + .await; + return Err(e); + } + + let stats = stats.unwrap(); + + let cli_version = env!("CARGO_PKG_VERSION"); + let execution_id: [u8; 20] = rand::thread_rng().gen(); + let execution_id = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + execution_id, + ); + + let _ = telemetry + .send_event( + BaseEvent { + kind: "codemodExecuted".to_string(), + properties: HashMap::from([ + ("codemodName".to_string(), args.package.clone()), + ("executionId".to_string(), execution_id.clone()), + ("fileCount".to_string(), stats.files_modified.to_string()), + ("cliVersion".to_string(), cli_version.to_string()), + ]), + }, + None, + ) + .await; Ok(()) } @@ -139,7 +194,7 @@ async fn execute_codemod( target_path: &Path, additional_args: &[String], dry_run: bool, -) -> Result<()> { +) -> Result { let workflow_path = package_dir.join("workflow.yaml"); info!( @@ -176,6 +231,11 @@ async fn execute_codemod( // Run workflow using the extracted workflow runner run_workflow(engine, config).await?; + let stats = GLOBAL_STATS + .get_or_init(|| Mutex::new(ExecutionStats::default())) + .lock() + .await + .clone(); - Ok(()) + Ok(stats) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c77ae56d9..99953b051 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,7 +1,6 @@ use anyhow::Result; use clap::{Args, Parser, Subcommand}; use log::info; - mod ascii_art; mod auth; mod auth_provider; @@ -10,6 +9,12 @@ mod dirty_git_check; mod engine; mod workflow_runner; use ascii_art::print_ascii_art; +use codemod_telemetry::{ + send_event::{PostHogSender, TelemetrySender, TelemetrySenderOptions}, + send_null::NullSender, +}; + +use crate::auth::TokenStorage; #[derive(Parser)] #[command(name = "codemod")] @@ -140,6 +145,7 @@ fn is_package_name(arg: &str) -> bool { async fn handle_implicit_run_command( engine: &butterflow_core::engine::Engine, trailing_args: Vec, + telemetry_sender: &dyn TelemetrySender, ) -> Result { if trailing_args.is_empty() { return Ok(false); @@ -158,7 +164,7 @@ async fn handle_implicit_run_command( match Cli::try_parse_from(&full_args) { Ok(new_cli) => { if let Some(Commands::Run(run_args)) = new_cli.command { - commands::run::handler(engine, &run_args).await?; + commands::run::handler(engine, &run_args, telemetry_sender).await?; Ok(true) } else { Ok(false) @@ -178,7 +184,7 @@ async fn handle_implicit_run_command( #[tokio::main] async fn main() -> Result<()> { // Initialize logger - env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); + env_logger::init_from_env(env_logger::Env::default().default_filter_or("error")); // Parse command line arguments let cli = Cli::parse(); @@ -193,6 +199,30 @@ async fn main() -> Result<()> { // Create engine let engine = engine::create_engine()?; + let telemetry_sender: Box = + if std::env::var("DISABLE_ANALYTICS") == Ok("true".to_string()) + || std::env::var("DISABLE_ANALYTICS") == Ok("1".to_string()) + { + Box::new(NullSender {}) + } else { + let storage = TokenStorage::new()?; + let config = storage.load_config()?; + + let auth = storage.get_auth_for_registry(&config.default_registry)?; + + let distinct_id = auth + .map(|auth| auth.user.id) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + Box::new( + PostHogSender::new(TelemetrySenderOptions { + distinct_id, + cloud_role: "CLI".to_string(), + }) + .await, + ) + }; + // Handle command or implicit run match &cli.command { Some(Commands::Workflow(args)) => match &args.command { @@ -236,13 +266,13 @@ async fn main() -> Result<()> { commands::whoami::handler(args).await?; } Some(Commands::Publish(args)) => { - commands::publish::handler(args).await?; + commands::publish::handler(args, telemetry_sender.as_ref()).await?; } Some(Commands::Search(args)) => { commands::search::handler(args).await?; } Some(Commands::Run(args)) => { - commands::run::handler(&engine, args).await?; + commands::run::handler(&engine, args, telemetry_sender.as_ref()).await?; } Some(Commands::Unpublish(args)) => { commands::unpublish::handler(args).await?; @@ -252,7 +282,9 @@ async fn main() -> Result<()> { } None => { // Try to parse as implicit run command - if !handle_implicit_run_command(&engine, cli.trailing_args).await? { + if !handle_implicit_run_command(&engine, cli.trailing_args, telemetry_sender.as_ref()) + .await? + { // No valid subcommand or package name provided, show help print_ascii_art(); eprintln!("No command provided. Use --help for usage information."); diff --git a/crates/core/src/engine.rs b/crates/core/src/engine.rs index 4534662be..98eadfe55 100644 --- a/crates/core/src/engine.rs +++ b/crates/core/src/engine.rs @@ -31,13 +31,18 @@ use butterflow_scheduler::Scheduler; use butterflow_state::local_adapter::LocalStateAdapter; use butterflow_state::StateAdapter; use codemod_sandbox::sandbox::{ - engine::{language_data::get_extensions_for_language, ExecutionConfig, ExecutionEngine}, + engine::{ + language_data::get_extensions_for_language, ExecutionConfig, ExecutionEngine, + ExecutionStats, + }, filesystem::{RealFileSystem, WalkOptions}, loaders::FileSystemLoader, resolvers::FileSystemResolver, }; use codemod_sandbox::{execute_ast_grep_on_globs, execute_ast_grep_on_globs_with_fixes}; +use std::sync::OnceLock; +pub static GLOBAL_STATS: OnceLock> = OnceLock::new(); /// Workflow engine pub struct Engine { /// State adapter for persisting workflow state @@ -1429,6 +1434,10 @@ impl Engine { info!("Modified files: {:?}", stats.files_modified); info!("Unmodified files: {:?}", stats.files_unmodified); info!("Files with errors: {:?}", stats.files_with_errors); + // set global stats + let data = GLOBAL_STATS.get_or_init(|| Mutex::new(ExecutionStats::default())); + let mut data = data.lock().await; + *data = stats.clone(); // TODO: Consider writing execution stats to state or logs // Similar to AST grep, this could be extended to: diff --git a/crates/telemetry/Cargo.toml b/crates/telemetry/Cargo.toml new file mode 100644 index 000000000..2a653b9e9 --- /dev/null +++ b/crates/telemetry/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codemod-telemetry" +edition = "2021" +authors.workspace = true +description.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true +build = "build.rs" + +[dependencies] +async-trait.workspace = true +posthog-rs = "0.3.7" +serde.workspace = true diff --git a/crates/telemetry/build.rs b/crates/telemetry/build.rs new file mode 100644 index 000000000..51fcc14c3 --- /dev/null +++ b/crates/telemetry/build.rs @@ -0,0 +1,4 @@ +fn main() { + let api_key = std::env::var("POSTHOG_API_KEY").unwrap_or_else(|_| "".to_string()); + println!("cargo:rustc-env=POSTHOG_API_KEY={api_key}"); +} diff --git a/crates/telemetry/src/lib.rs b/crates/telemetry/src/lib.rs new file mode 100644 index 000000000..e0ccee030 --- /dev/null +++ b/crates/telemetry/src/lib.rs @@ -0,0 +1,2 @@ +pub mod send_event; +pub mod send_null; diff --git a/crates/telemetry/src/send_event.rs b/crates/telemetry/src/send_event.rs new file mode 100644 index 000000000..c96be86e0 --- /dev/null +++ b/crates/telemetry/src/send_event.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use posthog_rs; +use serde::Serialize; +use std::collections::HashMap; +use std::env; + +#[derive(Debug, Clone)] +pub struct TelemetrySenderOptions { + pub distinct_id: String, + pub cloud_role: String, +} + +#[derive(Debug, Clone)] +pub struct PartialTelemetrySenderOptions { + pub distinct_id: Option, + pub cloud_role: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct BaseEvent { + pub kind: String, + #[serde(flatten)] + pub properties: HashMap, +} + +#[async_trait] +pub trait TelemetrySender { + async fn send_event( + &self, + event: BaseEvent, + options_override: Option, + ); +} + +pub struct PostHogSender { + client: posthog_rs::Client, + options: TelemetrySenderOptions, +} + +pub const POSTHOG_API_KEY: &str = env!("POSTHOG_API_KEY"); + +impl PostHogSender { + pub async fn new(options: TelemetrySenderOptions) -> Self { + let client = posthog_rs::client(POSTHOG_API_KEY).await; + Self { client, options } + } +} + +#[async_trait] +impl TelemetrySender for PostHogSender { + async fn send_event( + &self, + event: BaseEvent, + options_override: Option, + ) { + let distinct_id = options_override + .as_ref() + .and_then(|o| o.distinct_id.clone()) + .unwrap_or_else(|| self.options.distinct_id.clone()); + + let cloud_role = options_override + .as_ref() + .and_then(|o| o.cloud_role.clone()) + .unwrap_or_else(|| self.options.cloud_role.clone()); + + let mut posthog_event = posthog_rs::Event::new( + format!("codemod.{}.{}", cloud_role, event.kind), + distinct_id.clone(), + ); + + for (key, value) in event.properties { + if let Err(e) = posthog_event.insert_prop(key, value) { + eprintln!("Failed to insert property into PostHog event: {e}"); + } + } + + if let Err(e) = self.client.capture(posthog_event).await { + eprintln!("Failed to send PostHog event: {e}"); + } + } +} diff --git a/crates/telemetry/src/send_null.rs b/crates/telemetry/src/send_null.rs new file mode 100644 index 000000000..85268698c --- /dev/null +++ b/crates/telemetry/src/send_null.rs @@ -0,0 +1,15 @@ +use crate::send_event::{BaseEvent, PartialTelemetrySenderOptions, TelemetrySender}; +use async_trait::async_trait; + +pub struct NullSender; + +#[async_trait] +impl TelemetrySender for NullSender { + async fn send_event( + &self, + _event: BaseEvent, + _options_override: Option, + ) { + // Do nothing + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy