From 3cd1917dcb76cc5870cbe6017494974707c64e02 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 13:07:14 +0200 Subject: [PATCH 01/10] feat(cli): add dsym upload command --- cli/Cargo.lock | 151 +++++++++++++++++++++++++++++ cli/Cargo.toml | 2 + cli/src/commands.rs | 13 +++ cli/src/dsym/mod.rs | 211 +++++++++++++++++++++++++++++++++++++++++ cli/src/dsym/upload.rs | 194 +++++++++++++++++++++++++++++++++++++ cli/src/lib.rs | 1 + 6 files changed, 572 insertions(+) create mode 100644 cli/src/dsym/mod.rs create mode 100644 cli/src/dsym/upload.rs diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 1e895c54a938..a9064c240b89 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -97,6 +97,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -358,6 +367,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -485,6 +503,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -622,6 +660,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1380,6 +1428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1424,6 +1473,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -1519,6 +1574,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "posthog-cli" version = "0.5.16" @@ -1532,6 +1600,7 @@ dependencies = [ "inquire", "magic_string", "miette", + "plist", "posthog-rs", "posthog-symbol-data", "proguard", @@ -1550,6 +1619,7 @@ dependencies = [ "urlencoding", "uuid", "walkdir", + "zip", ] [[package]] @@ -1586,6 +1656,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1616,6 +1692,15 @@ dependencies = [ "watto", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2166,6 +2251,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.11" @@ -2438,6 +2529,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -3355,3 +3477,32 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.17", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index f263ad75fda6..78c052571b40 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -50,6 +50,8 @@ posthog-rs = { version = "0.3.5", default-features = false } sha2 = "0.10.9" urlencoding = "2.1.3" rayon = "1.11.0" +zip = { version = "2.2", default-features = false, features = ["deflate"] } +plist = "1" [dev-dependencies] test-log = "0.2.17" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 2906ad780dc9..6b8fcdea5b12 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2,6 +2,7 @@ use clap::{Parser, Subcommand}; use tracing::error; use crate::{ + dsym::DsymSubcommand, error::CapturedError, experimental::{query::command::QueryCommand, tasks::TaskCommand}, invocation_context::{context, init_context}, @@ -75,6 +76,13 @@ pub enum ExpCommand { #[command(subcommand)] cmd: ProguardSubcommand, }, + + #[command(about = "Upload iOS/macOS dSYM files to PostHog")] + Dsym { + #[command(subcommand)] + cmd: DsymSubcommand, + }, + /// Download event definitions and generate typed SDK Schema { #[command(subcommand)] @@ -165,6 +173,11 @@ impl Cli { crate::proguard::upload::upload(&args)?; } }, + ExpCommand::Dsym { cmd } => match cmd { + DsymSubcommand::Upload(args) => { + crate::dsym::upload::upload(&args)?; + } + }, ExpCommand::Schema { cmd } => match cmd { SchemaCommand::Pull { output } => { crate::experimental::schema::pull(self.host, output)?; diff --git a/cli/src/dsym/mod.rs b/cli/src/dsym/mod.rs new file mode 100644 index 000000000000..53dce17884a6 --- /dev/null +++ b/cli/src/dsym/mod.rs @@ -0,0 +1,211 @@ +use std::path::PathBuf; + +use crate::api::symbol_sets::SymbolSetUpload; +use anyhow::{anyhow, Result}; +use clap::Subcommand; + +pub mod upload; + +#[derive(Subcommand)] +pub enum DsymSubcommand { + /// Upload iOS/macOS dSYM files + Upload(upload::Args), +} + +/// Represents a dSYM bundle ready for upload +pub struct DsymFile { + /// The UUID of the dSYM (used as chunk_id for matching) + pub uuid: String, + /// The zipped dSYM bundle data + pub data: Vec, + /// Optional release ID + pub release_id: Option, +} + +impl DsymFile { + /// Create a new DsymFile from a .dSYM bundle path + pub fn new(path: &PathBuf) -> Result { + // Validate it's a dSYM bundle + if !path.is_dir() { + anyhow::bail!("Path {} is not a directory", path.display()); + } + + let extension = path.extension().and_then(|e| e.to_str()); + if extension != Some("dSYM") { + anyhow::bail!( + "Path {} is not a dSYM bundle (expected .dSYM extension)", + path.display() + ); + } + + // Extract UUID from the dSYM + let uuid = extract_dsym_uuid(path)?; + + // Zip the dSYM bundle + let data = zip_dsym_bundle(path)?; + + Ok(Self { + uuid, + data, + release_id: None, + }) + } +} + +impl TryInto for DsymFile { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + Ok(SymbolSetUpload { + chunk_id: self.uuid, + release_id: self.release_id, + data: self.data, + }) + } +} + +/// Extract the UUID from a dSYM bundle using dwarfdump +fn extract_dsym_uuid(dsym_path: &PathBuf) -> Result { + use std::process::Command; + + let output = Command::new("dwarfdump") + .arg("--uuid") + .arg(dsym_path) + .output() + .map_err(|e| anyhow!("Failed to run dwarfdump: {}. Is Xcode installed?", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("dwarfdump failed: {}", stderr); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse output like: "UUID: 12345678-1234-1234-1234-123456789ABC (arm64) /path/to/file" + // There may be multiple UUIDs for universal binaries, we take the first one + for line in stdout.lines() { + if let Some(uuid_start) = line.find("UUID: ") { + let uuid_part = &line[uuid_start + 6..]; + if let Some(uuid_end) = uuid_part.find(' ') { + let uuid = &uuid_part[..uuid_end]; + // Uppercase for standard UUID format + return Ok(uuid.to_uppercase()); + } + } + } + + anyhow::bail!( + "Could not extract UUID from dSYM at {}. dwarfdump output: {}", + dsym_path.display(), + stdout + ) +} + +/// Zip a dSYM bundle into memory +fn zip_dsym_bundle(dsym_path: &PathBuf) -> Result> { + use std::io::{Cursor, Write}; + use std::fs::File; + use std::io::Read; + use walkdir::WalkDir; + + let mut buffer = Cursor::new(Vec::new()); + + { + let mut zip = zip::ZipWriter::new(&mut buffer); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let _dsym_name = dsym_path + .file_name() + .ok_or_else(|| anyhow!("Invalid dSYM path"))? + .to_string_lossy(); + + for entry in WalkDir::new(dsym_path) { + let entry = entry?; + let path = entry.path(); + + // Create relative path within the zip + let relative_path = path.strip_prefix(dsym_path.parent().unwrap_or(dsym_path))?; + let zip_path = relative_path.to_string_lossy(); + + if path.is_file() { + zip.start_file(zip_path.to_string(), options)?; + let mut file = File::open(path)?; + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + zip.write_all(&contents)?; + } else if path.is_dir() && path != dsym_path.as_path() { + // Add directory entry (but not the root) + zip.add_directory(format!("{}/", zip_path), options)?; + } + } + + zip.finish()?; + } + + Ok(buffer.into_inner()) +} + +/// Find all dSYM bundles in a directory +pub fn find_dsym_bundles(directory: &PathBuf) -> Result> { + use walkdir::WalkDir; + + let mut dsyms = Vec::new(); + + for entry in WalkDir::new(directory).follow_links(true) { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + if let Some(ext) = path.extension() { + if ext == "dSYM" { + dsyms.push(path.to_path_buf()); + } + } + } + } + + Ok(dsyms) +} + +/// Info extracted from a dSYM's Info.plist +#[derive(Debug, Clone, Default)] +pub struct PlistInfo { + /// CFBundleIdentifier (e.g., com.example.app) + pub bundle_identifier: Option, + /// CFBundleShortVersionString (e.g., 1.2.3) + pub short_version: Option, + /// CFBundleVersion (e.g., 42) + pub bundle_version: Option, +} + +/// Extract version info from a dSYM bundle's Info.plist +pub fn extract_plist_info(dsym_path: &PathBuf) -> Result { + let plist_path = dsym_path.join("Contents/Info.plist"); + + if !plist_path.exists() { + anyhow::bail!("Info.plist not found at {}", plist_path.display()); + } + + let plist = plist::Value::from_file(&plist_path) + .map_err(|e| anyhow!("Failed to parse Info.plist: {}", e))?; + + let dict = plist + .as_dictionary() + .ok_or_else(|| anyhow!("Info.plist is not a dictionary"))?; + + Ok(PlistInfo { + bundle_identifier: dict + .get("CFBundleIdentifier") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()), + short_version: dict + .get("CFBundleShortVersionString") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()), + bundle_version: dict + .get("CFBundleVersion") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()), + }) +} diff --git a/cli/src/dsym/upload.rs b/cli/src/dsym/upload.rs new file mode 100644 index 000000000000..3b1dc434c6cb --- /dev/null +++ b/cli/src/dsym/upload.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use tracing::info; + +use crate::{ + api::{self, releases::ReleaseBuilder, symbol_sets::SymbolSetUpload}, + dsym::{extract_plist_info, find_dsym_bundles, DsymFile}, + utils::git::get_git_info, +}; + +#[derive(clap::Args, Clone)] +pub struct Args { + /// The directory containing dSYM files to upload. This is typically $DWARF_DSYM_FOLDER_PATH + /// when running from an Xcode build phase. + #[arg(short, long)] + pub directory: PathBuf, + + /// The bundle identifier (e.g., com.example.app). + /// If not provided, will be extracted from dSYM Info.plist. + #[arg(long)] + pub project: Option, + + /// The marketing version (e.g., 1.2.3, CFBundleShortVersionString). + /// If not provided, will be extracted from dSYM Info.plist. + #[arg(long)] + pub version: Option, + + /// The build number (e.g., 42, CFBundleVersion). + /// If not provided, will be extracted from dSYM Info.plist. + #[arg(long)] + pub build: Option, + + /// The main dSYM file name (e.g., MyApp.app.dSYM). + /// Used to extract version info from the correct dSYM when multiple are present. + /// This is typically $DWARF_DSYM_FILE_NAME in Xcode build phases. + #[arg(long)] + pub main_dsym: Option, +} + +pub fn upload(args: &Args) -> Result<()> { + let Args { + directory, + project, + version, + build, + main_dsym, + } = args; + + let directory = directory + .canonicalize() + .map_err(|e| anyhow!("Path {} canonicalization failed: {}", directory.display(), e))?; + + if !directory.is_dir() { + anyhow::bail!("Path {} is not a directory", directory.display()); + } + + // Find all dSYM bundles + let dsym_paths = find_dsym_bundles(&directory)?; + + if dsym_paths.is_empty() { + info!("No dSYM bundles found in {}", directory.display()); + return Ok(()); + } + + info!("Found {} dSYM bundle(s)", dsym_paths.len()); + + // Find the main dSYM to extract version info from + // Priority: --main-dsym flag > first .app.dSYM > first dSYM + let main_dsym_path = if let Some(main_name) = main_dsym { + // Use the specified main dSYM + dsym_paths + .iter() + .find(|p| { + p.file_name() + .map(|n| n.to_string_lossy() == *main_name) + .unwrap_or(false) + }) + .cloned() + } else { + // Try to find the app dSYM (not a framework) + dsym_paths + .iter() + .find(|p| { + p.file_name() + .map(|n| n.to_string_lossy().ends_with(".app.dSYM")) + .unwrap_or(false) + }) + .cloned() + } + .or_else(|| dsym_paths.first().cloned()); + + // Extract info from main dSYM's Info.plist as fallback + let plist_info = main_dsym_path.as_ref().and_then(|p| { + match extract_plist_info(p) { + Ok(info) => { + info!( + "Extracted plist info from {}: {:?}", + p.file_name().unwrap_or_default().to_string_lossy(), + info + ); + Some(info) + } + Err(e) => { + tracing::debug!("Could not extract plist info: {}", e); + None + } + } + }); + + // Determine project, version, build - CLI args take precedence over plist + let resolved_project = project + .clone() + .or_else(|| plist_info.as_ref().and_then(|p| p.bundle_identifier.clone())); + let resolved_version = version + .clone() + .or_else(|| plist_info.as_ref().and_then(|p| p.short_version.clone())); + let resolved_build = build + .clone() + .or_else(|| plist_info.as_ref().and_then(|p| p.bundle_version.clone())); + + // Build full version string: "version+build" or just "version" or just "build" + let full_version = match (&resolved_version, &resolved_build) { + (Some(v), Some(b)) => Some(format!("{}+{}", v, b)), + (Some(v), None) => Some(v.clone()), + (None, Some(b)) => Some(b.clone()), + (None, None) => None, + }; + + if let Some(ref proj) = resolved_project { + info!("Project: {}", proj); + } + if let Some(ref ver) = full_version { + info!("Version: {}", ver); + } + + // Set up release info + let mut release_builder = ReleaseBuilder::default(); + + // Add git info as metadata if available (but don't use it for project/version) + if let Ok(Some(git_info)) = get_git_info(Some(directory.clone())) { + release_builder.with_git(git_info); + } + + if let Some(ref project) = resolved_project { + release_builder.with_project(project); + } + if let Some(ref version) = full_version { + release_builder.with_version(version); + } + + let release = release_builder + .can_create() + .then(|| release_builder.fetch_or_create()) + .transpose()?; + + let release_id = release.map(|r| r.id.to_string()); + + // Process each dSYM + let mut uploads: Vec = Vec::new(); + + for dsym_path in dsym_paths { + info!("Processing dSYM: {}", dsym_path.display()); + + match DsymFile::new(&dsym_path) { + Ok(mut dsym_file) => { + dsym_file.release_id = release_id.clone(); + info!(" UUID: {}", dsym_file.uuid); + info!(" Size: {} bytes", dsym_file.data.len()); + + match dsym_file.try_into() { + Ok(upload) => uploads.push(upload), + Err(e) => { + tracing::warn!("Failed to prepare dSYM for upload: {}", e); + } + } + } + Err(e) => { + tracing::warn!("Failed to process dSYM {}: {}", dsym_path.display(), e); + } + } + } + + if uploads.is_empty() { + info!("No dSYMs to upload"); + return Ok(()); + } + + info!("Uploading {} dSYM(s)...", uploads.len()); + api::symbol_sets::upload(&uploads, 10)?; + info!("dSYM upload complete"); + + Ok(()) +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index ed9106f0f1f1..c4dc6d7ac8a4 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod commands; +pub mod dsym; pub mod error; pub mod experimental; pub mod invocation_context; From aa8f3f7313df6b2cb9406c34fd2f77a42076db71 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 14:27:58 +0200 Subject: [PATCH 02/10] fix: refactor helper to extract PlistInfo from path --- cli/src/dsym/mod.rs | 57 +++++++++++++++++++++--------------------- cli/src/dsym/upload.rs | 5 ++-- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/cli/src/dsym/mod.rs b/cli/src/dsym/mod.rs index 53dce17884a6..f0f5197347b9 100644 --- a/cli/src/dsym/mod.rs +++ b/cli/src/dsym/mod.rs @@ -168,7 +168,7 @@ pub fn find_dsym_bundles(directory: &PathBuf) -> Result> { Ok(dsyms) } -/// Info extracted from a dSYM's Info.plist +/// Info extracted from an Info.plist file #[derive(Debug, Clone, Default)] pub struct PlistInfo { /// CFBundleIdentifier (e.g., com.example.app) @@ -179,33 +179,34 @@ pub struct PlistInfo { pub bundle_version: Option, } -/// Extract version info from a dSYM bundle's Info.plist -pub fn extract_plist_info(dsym_path: &PathBuf) -> Result { - let plist_path = dsym_path.join("Contents/Info.plist"); +impl PlistInfo { + /// Extract version info from an Info.plist file path + pub fn from_plist(plist_path: &PathBuf) -> Result { + if !plist_path.exists() { + anyhow::bail!("Info.plist not found at {}", plist_path.display()); + } - if !plist_path.exists() { - anyhow::bail!("Info.plist not found at {}", plist_path.display()); - } + let plist = plist::Value::from_file(plist_path) + .map_err(|e| anyhow!("Failed to parse Info.plist: {}", e))?; - let plist = plist::Value::from_file(&plist_path) - .map_err(|e| anyhow!("Failed to parse Info.plist: {}", e))?; - - let dict = plist - .as_dictionary() - .ok_or_else(|| anyhow!("Info.plist is not a dictionary"))?; - - Ok(PlistInfo { - bundle_identifier: dict - .get("CFBundleIdentifier") - .and_then(|v| v.as_string()) - .map(|s| s.to_string()), - short_version: dict - .get("CFBundleShortVersionString") - .and_then(|v| v.as_string()) - .map(|s| s.to_string()), - bundle_version: dict - .get("CFBundleVersion") - .and_then(|v| v.as_string()) - .map(|s| s.to_string()), - }) + let dict = plist + .as_dictionary() + .ok_or_else(|| anyhow!("Info.plist is not a dictionary"))?; + + Ok(Self { + bundle_identifier: dict + .get("CFBundleIdentifier") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()), + short_version: dict + .get("CFBundleShortVersionString") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()), + bundle_version: dict + .get("CFBundleVersion") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()), + }) + } } + diff --git a/cli/src/dsym/upload.rs b/cli/src/dsym/upload.rs index 3b1dc434c6cb..5a0661f26112 100644 --- a/cli/src/dsym/upload.rs +++ b/cli/src/dsym/upload.rs @@ -5,7 +5,7 @@ use tracing::info; use crate::{ api::{self, releases::ReleaseBuilder, symbol_sets::SymbolSetUpload}, - dsym::{extract_plist_info, find_dsym_bundles, DsymFile}, + dsym::{find_dsym_bundles, DsymFile, PlistInfo}, utils::git::get_git_info, }; @@ -92,7 +92,8 @@ pub fn upload(args: &Args) -> Result<()> { // Extract info from main dSYM's Info.plist as fallback let plist_info = main_dsym_path.as_ref().and_then(|p| { - match extract_plist_info(p) { + let plist_path = p.join("Contents/Info.plist"); + match PlistInfo::from_plist(&plist_path) { Ok(info) => { info!( "Extracted plist info from {}: {:?}", From a5c6c1acdefcef900905e643b0c756d42ddf504a Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 15:51:20 +0200 Subject: [PATCH 03/10] feat: add dsym info as release meta --- cli/src/dsym/mod.rs | 12 +++++++++++- cli/src/dsym/upload.rs | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cli/src/dsym/mod.rs b/cli/src/dsym/mod.rs index f0f5197347b9..ceecb678a843 100644 --- a/cli/src/dsym/mod.rs +++ b/cli/src/dsym/mod.rs @@ -169,14 +169,20 @@ pub fn find_dsym_bundles(directory: &PathBuf) -> Result> { } /// Info extracted from an Info.plist file -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Serialize)] pub struct PlistInfo { /// CFBundleIdentifier (e.g., com.example.app) + #[serde(rename = "CFBundleIdentifier")] pub bundle_identifier: Option, /// CFBundleShortVersionString (e.g., 1.2.3) + #[serde(rename = "CFBundleShortVersionString")] pub short_version: Option, /// CFBundleVersion (e.g., 42) + #[serde(rename = "CFBundleVersion")] pub bundle_version: Option, + /// CFBundleDevelopmentRegion (e.g., English, en) + #[serde(rename = "CFBundleDevelopmentRegion")] + pub development_region: Option, } impl PlistInfo { @@ -206,6 +212,10 @@ impl PlistInfo { .get("CFBundleVersion") .and_then(|v| v.as_string()) .map(|s| s.to_string()), + development_region: dict + .get("CFBundleDevelopmentRegion") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()), }) } } diff --git a/cli/src/dsym/upload.rs b/cli/src/dsym/upload.rs index 5a0661f26112..ac769fe14c05 100644 --- a/cli/src/dsym/upload.rs +++ b/cli/src/dsym/upload.rs @@ -143,6 +143,11 @@ pub fn upload(args: &Args) -> Result<()> { release_builder.with_git(git_info); } + // Add plist info as apple metadata + if let Some(ref info) = plist_info { + let _ = release_builder.with_metadata("dsym_info", info); + } + if let Some(ref project) = resolved_project { release_builder.with_project(project); } From bffacdeb4f3f62e5ae1115b8bd4cf8bba026c66c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 15:52:30 +0200 Subject: [PATCH 04/10] chore: format and lint --- cli/src/dsym/mod.rs | 21 ++++++++++----------- cli/src/dsym/upload.rs | 20 +++++++++++++------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/cli/src/dsym/mod.rs b/cli/src/dsym/mod.rs index ceecb678a843..f517e6b4a409 100644 --- a/cli/src/dsym/mod.rs +++ b/cli/src/dsym/mod.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::api::symbol_sets::SymbolSetUpload; use anyhow::{anyhow, Result}; @@ -72,15 +72,15 @@ fn extract_dsym_uuid(dsym_path: &PathBuf) -> Result { .arg("--uuid") .arg(dsym_path) .output() - .map_err(|e| anyhow!("Failed to run dwarfdump: {}. Is Xcode installed?", e))?; + .map_err(|e| anyhow!("Failed to run dwarfdump: {e}. Is Xcode installed?"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("dwarfdump failed: {}", stderr); + anyhow::bail!("dwarfdump failed: {stderr}"); } let stdout = String::from_utf8_lossy(&output.stdout); - + // Parse output like: "UUID: 12345678-1234-1234-1234-123456789ABC (arm64) /path/to/file" // There may be multiple UUIDs for universal binaries, we take the first one for line in stdout.lines() { @@ -103,13 +103,13 @@ fn extract_dsym_uuid(dsym_path: &PathBuf) -> Result { /// Zip a dSYM bundle into memory fn zip_dsym_bundle(dsym_path: &PathBuf) -> Result> { - use std::io::{Cursor, Write}; use std::fs::File; use std::io::Read; + use std::io::{Cursor, Write}; use walkdir::WalkDir; let mut buffer = Cursor::new(Vec::new()); - + { let mut zip = zip::ZipWriter::new(&mut buffer); let options = zip::write::SimpleFileOptions::default() @@ -123,7 +123,7 @@ fn zip_dsym_bundle(dsym_path: &PathBuf) -> Result> { for entry in WalkDir::new(dsym_path) { let entry = entry?; let path = entry.path(); - + // Create relative path within the zip let relative_path = path.strip_prefix(dsym_path.parent().unwrap_or(dsym_path))?; let zip_path = relative_path.to_string_lossy(); @@ -136,7 +136,7 @@ fn zip_dsym_bundle(dsym_path: &PathBuf) -> Result> { zip.write_all(&contents)?; } else if path.is_dir() && path != dsym_path.as_path() { // Add directory entry (but not the root) - zip.add_directory(format!("{}/", zip_path), options)?; + zip.add_directory(format!("{zip_path}/"), options)?; } } @@ -187,13 +187,13 @@ pub struct PlistInfo { impl PlistInfo { /// Extract version info from an Info.plist file path - pub fn from_plist(plist_path: &PathBuf) -> Result { + pub fn from_plist(plist_path: &Path) -> Result { if !plist_path.exists() { anyhow::bail!("Info.plist not found at {}", plist_path.display()); } let plist = plist::Value::from_file(plist_path) - .map_err(|e| anyhow!("Failed to parse Info.plist: {}", e))?; + .map_err(|e| anyhow!("Failed to parse Info.plist: {e}"))?; let dict = plist .as_dictionary() @@ -219,4 +219,3 @@ impl PlistInfo { }) } } - diff --git a/cli/src/dsym/upload.rs b/cli/src/dsym/upload.rs index ac769fe14c05..636f4808b5c1 100644 --- a/cli/src/dsym/upload.rs +++ b/cli/src/dsym/upload.rs @@ -47,9 +47,13 @@ pub fn upload(args: &Args) -> Result<()> { main_dsym, } = args; - let directory = directory - .canonicalize() - .map_err(|e| anyhow!("Path {} canonicalization failed: {}", directory.display(), e))?; + let directory = directory.canonicalize().map_err(|e| { + anyhow!( + "Path {} canonicalization failed: {}", + directory.display(), + e + ) + })?; if !directory.is_dir() { anyhow::bail!("Path {} is not a directory", directory.display()); @@ -110,9 +114,11 @@ pub fn upload(args: &Args) -> Result<()> { }); // Determine project, version, build - CLI args take precedence over plist - let resolved_project = project - .clone() - .or_else(|| plist_info.as_ref().and_then(|p| p.bundle_identifier.clone())); + let resolved_project = project.clone().or_else(|| { + plist_info + .as_ref() + .and_then(|p| p.bundle_identifier.clone()) + }); let resolved_version = version .clone() .or_else(|| plist_info.as_ref().and_then(|p| p.short_version.clone())); @@ -122,7 +128,7 @@ pub fn upload(args: &Args) -> Result<()> { // Build full version string: "version+build" or just "version" or just "build" let full_version = match (&resolved_version, &resolved_build) { - (Some(v), Some(b)) => Some(format!("{}+{}", v, b)), + (Some(v), Some(b)) => Some(format!("{v}+{b}")), (Some(v), None) => Some(v.clone()), (None, Some(b)) => Some(b.clone()), (None, None) => None, From 90b9b3c44d32c9a0d2d7cf542476367b4330cd97 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 16:36:18 +0200 Subject: [PATCH 05/10] fix: upload all UUIDs --- cli/src/dsym/mod.rs | 71 ++++++++++++++++++++++++------------------ cli/src/dsym/upload.rs | 13 ++++---- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/cli/src/dsym/mod.rs b/cli/src/dsym/mod.rs index f517e6b4a409..378691b8d0a0 100644 --- a/cli/src/dsym/mod.rs +++ b/cli/src/dsym/mod.rs @@ -14,8 +14,8 @@ pub enum DsymSubcommand { /// Represents a dSYM bundle ready for upload pub struct DsymFile { - /// The UUID of the dSYM (used as chunk_id for matching) - pub uuid: String, + /// The UUIDs of the dSYM (one per architecture, used as chunk_id for matching) + pub uuids: Vec, /// The zipped dSYM bundle data pub data: Vec, /// Optional release ID @@ -38,34 +38,37 @@ impl DsymFile { ); } - // Extract UUID from the dSYM - let uuid = extract_dsym_uuid(path)?; + // Extract UUIDs from the dSYM (one per architecture for universal binaries) + let uuids = extract_dsym_uuids(path)?; // Zip the dSYM bundle let data = zip_dsym_bundle(path)?; Ok(Self { - uuid, + uuids, data, release_id: None, }) } } -impl TryInto for DsymFile { - type Error = anyhow::Error; - - fn try_into(self) -> Result { - Ok(SymbolSetUpload { - chunk_id: self.uuid, - release_id: self.release_id, - data: self.data, - }) +impl DsymFile { + /// Convert to SymbolSetUploads (one per UUID/architecture) + pub fn into_uploads(self) -> Vec { + self.uuids + .into_iter() + .map(|uuid| SymbolSetUpload { + chunk_id: uuid, + release_id: self.release_id.clone(), + data: self.data.clone(), + }) + .collect() } } -/// Extract the UUID from a dSYM bundle using dwarfdump -fn extract_dsym_uuid(dsym_path: &PathBuf) -> Result { +/// Extract all UUIDs from a dSYM bundle using dwarfdump +/// Universal binaries have multiple UUIDs (one per architecture) +fn extract_dsym_uuids(dsym_path: &PathBuf) -> Result> { use std::process::Command; let output = Command::new("dwarfdump") @@ -82,23 +85,29 @@ fn extract_dsym_uuid(dsym_path: &PathBuf) -> Result { let stdout = String::from_utf8_lossy(&output.stdout); // Parse output like: "UUID: 12345678-1234-1234-1234-123456789ABC (arm64) /path/to/file" - // There may be multiple UUIDs for universal binaries, we take the first one - for line in stdout.lines() { - if let Some(uuid_start) = line.find("UUID: ") { - let uuid_part = &line[uuid_start + 6..]; - if let Some(uuid_end) = uuid_part.find(' ') { - let uuid = &uuid_part[..uuid_end]; - // Uppercase for standard UUID format - return Ok(uuid.to_uppercase()); - } - } + // Universal binaries have multiple lines, one per architecture + let uuids: Vec = stdout + .lines() + .filter_map(|line| { + line.find("UUID: ").and_then(|uuid_start| { + let uuid_part = &line[uuid_start + 6..]; + uuid_part.find(' ').map(|uuid_end| { + // Uppercase for standard UUID format + uuid_part[..uuid_end].to_uppercase() + }) + }) + }) + .collect(); + + if uuids.is_empty() { + anyhow::bail!( + "Could not extract any UUIDs from dSYM at {}. dwarfdump output: {}", + dsym_path.display(), + stdout + ); } - anyhow::bail!( - "Could not extract UUID from dSYM at {}. dwarfdump output: {}", - dsym_path.display(), - stdout - ) + Ok(uuids) } /// Zip a dSYM bundle into memory diff --git a/cli/src/dsym/upload.rs b/cli/src/dsym/upload.rs index 636f4808b5c1..69e7c0d4d395 100644 --- a/cli/src/dsym/upload.rs +++ b/cli/src/dsym/upload.rs @@ -177,15 +177,14 @@ pub fn upload(args: &Args) -> Result<()> { match DsymFile::new(&dsym_path) { Ok(mut dsym_file) => { dsym_file.release_id = release_id.clone(); - info!(" UUID: {}", dsym_file.uuid); + info!( + " UUIDs: {} ({})", + dsym_file.uuids.join(", "), + dsym_file.uuids.len() + ); info!(" Size: {} bytes", dsym_file.data.len()); - match dsym_file.try_into() { - Ok(upload) => uploads.push(upload), - Err(e) => { - tracing::warn!("Failed to prepare dSYM for upload: {}", e); - } - } + uploads.extend(dsym_file.into_uploads()); } Err(e) => { tracing::warn!("Failed to process dSYM {}: {}", dsym_path.display(), e); From 17147d1b2ca424498638702635b972665a5081f2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 17:09:15 +0200 Subject: [PATCH 06/10] chore: bump version --- cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 1389dfa05392..65d299ce0fd1 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -1589,7 +1589,7 @@ dependencies = [ [[package]] name = "posthog-cli" -version = "0.5.19" +version = "0.6.0" dependencies = [ "anyhow", "chrono", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 858c7dc48308..acb24a2b5f84 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "posthog-cli" -version = "0.5.19" +version = "0.6.0" authors = [ "David ", "Olly ", From 708f1428f2519230a64ec66e321daa8a4d1d9fd7 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 23 Dec 2025 14:12:09 +0200 Subject: [PATCH 07/10] fix: remove unused var --- cli/src/dsym/mod.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cli/src/dsym/mod.rs b/cli/src/dsym/mod.rs index 378691b8d0a0..788fec871f37 100644 --- a/cli/src/dsym/mod.rs +++ b/cli/src/dsym/mod.rs @@ -124,11 +124,6 @@ fn zip_dsym_bundle(dsym_path: &PathBuf) -> Result> { let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); - let _dsym_name = dsym_path - .file_name() - .ok_or_else(|| anyhow!("Invalid dSYM path"))? - .to_string_lossy(); - for entry in WalkDir::new(dsym_path) { let entry = entry?; let path = entry.path(); From 897155e82fd6fa554901b8d8190653f4060de2f7 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 23 Dec 2025 14:25:41 +0200 Subject: [PATCH 08/10] Bump version number --- cli/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index cf2c72f17fd8..7b977b279830 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,8 +1,8 @@ # posthog-cli -# 0.5.20 +# 0.6.0 -- chore: add global `--rate-limit` option for Posthog client +- Add experimental dSYM upload for iOS/macOS crash symbolication # 0.5.19 From 2fbc5b5621ab40740975c02917ebc252358bd1b7 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 16 Feb 2026 10:08:16 +0200 Subject: [PATCH 09/10] fix: lockfile merge --- cli/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 4eb556b1c54b..7e797b09391d 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3580,3 +3580,4 @@ dependencies = [ "crc32fast", "log", "simd-adler32", +] \ No newline at end of file From b0d2a444807c35bfd510fd81264b0f4d2d9481f0 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 16 Feb 2026 10:45:38 +0200 Subject: [PATCH 10/10] fix: lockfile --- cli/Cargo.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 7e797b09391d..480d5e271046 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -514,9 +514,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", ] @@ -671,9 +671,9 @@ checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1503,9 +1503,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -2315,16 +2315,16 @@ dependencies = [ ] [[package]] -name = "similar" -version = "2.7.0" +name = "simd-adler32" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] -name = "simd-adler32" -version = "0.3.7" +name = "similar" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "slab" @@ -2600,30 +2600,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3580,4 +3580,4 @@ dependencies = [ "crc32fast", "log", "simd-adler32", -] \ No newline at end of file +]