Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cli/.sampo/changesets/sullen-runesinger-kalma.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
cargo/posthog-cli: minor
---

Support Flutter AOT symbols and opt-in app source context
10 changes: 9 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,15 @@ A single command handles both desktop/server formats:
- **macOS (Apple `.dSYM`):** dSYM bundles are packaged through the same path as `posthog-cli dsym upload`. That path shells out to `dwarfdump` (bundled with Xcode), so it only runs on macOS — if `dwarfdump` is missing, the bundle is reported and skipped while any ELF symbols in the same directory still upload.
- **macOS (Mach-O executables):** binaries that embed their own DWARF upload directly, keyed by their `LC_UUID`; universal (fat) binaries upload one symbol set per architecture slice. This branch is cross-platform. It exists mainly for Go, which never produces a dSYM (`dsymutil` reports "no debug symbols in executable") — but note Go compresses the embedded DWARF by default, which symbolication can't read yet, so build with `-ldflags=-compressdwarf=false`. C/Swift/Rust executables normally carry no embedded DWARF; upload their dSYMs instead.

Pass `--include-source` to bundle the referenced source files for richer context around frames.
Pass `--include-source` to bundle referenced source files for context around frames.
For Flutter `.symbols` files, add `--source-root <app-directory>` to include only source files inside your app directory:

```bash
posthog-cli symbol-sets upload --directory ./build/debug-info --include-source --source-root ./lib
```

This option limits uploaded source files, not the debug symbols scanned under `--directory`.
Upload from the build machine while those source files are still available.

The standalone `posthog-cli dsym upload` command is unchanged and still recommended for dSYM-only Xcode build phases, where it also reads release and version metadata from each bundle's `Info.plist`.

Expand Down
57 changes: 28 additions & 29 deletions cli/src/debug_symbols/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ impl DebugSymbolFile {
self,
release_id: Option<String>,
include_source: bool,
source_root: Option<&Path>,
) -> Result<SymbolSetUpload> {
use std::io::{Cursor, Write};

Expand All @@ -81,11 +82,11 @@ impl DebugSymbolFile {
if include_source {
match source_bundle::extract_source_paths_from_dwarf_bytes(&self.data) {
Ok(all_paths) => {
let filtered = filter_native_source_paths(&all_paths);
info!(
"Found {} source paths in DWARF ({} after filtering)",
all_paths.len(),
filtered.len()
let filtered = source_bundle::filter_source_paths_for_upload(
&all_paths,
source_root,
&self.path,
is_native_source_path,
);
if !filtered.is_empty() {
match source_bundle::collect_source_files(&filtered) {
Expand Down Expand Up @@ -126,28 +127,22 @@ impl DebugSymbolFile {
}
}

/// Filter DWARF source paths for native (Rust/C/C++) builds: on top of the
/// shared system-path filters, drop registry/toolchain sources that aren't
/// part of the user's project tree. Go toolchain sources are already trimmed
/// inside the shared extraction (see `filter_go_toolchain_sources`).
fn filter_native_source_paths(paths: &[String]) -> Vec<&str> {
source_bundle::filter_source_paths(paths)
.into_iter()
.filter(|path| {
const EXCLUDED: &[&str] = &[
"/.cargo/registry/",
"/.cargo/git/",
"/rustc/",
"/.rustup/toolchains/",
"/vendor/",
];
if EXCLUDED.iter().any(|sub| path.contains(sub)) {
tracing::debug!("Filtered out (native toolchain): {}", path);
return false;
}
true
})
.collect()
/// Drop native registry/toolchain sources outside the user's project tree.
/// Go toolchain sources are already trimmed inside the shared extraction
/// (see `filter_go_toolchain_sources`).
fn is_native_source_path(path: &str) -> bool {
const EXCLUDED: &[&str] = &[
"/.cargo/registry/",
"/.cargo/git/",
"/rustc/",
"/.rustup/toolchains/",
"/vendor/",
];
if EXCLUDED.iter().any(|sub| path.contains(sub)) {
tracing::debug!("Filtered out (native toolchain): {}", path);
return false;
}
true
}

/// Walk `directory` and classify everything that looks like native debug
Expand Down Expand Up @@ -440,10 +435,14 @@ pub fn report_problems(report: &DiscoveryReport, directory: &Path) -> Result<()>
///
/// Uploads come back with no release id; the caller stamps one (if any) after it
/// knows there is something to upload, so a fully-skipped run leaves no release.
pub fn package_dsym_bundles(bundles: &[PathBuf], include_source: bool) -> Vec<SymbolSetUpload> {
pub fn package_dsym_bundles(
bundles: &[PathBuf],
include_source: bool,
source_root: Option<&Path>,
) -> Vec<SymbolSetUpload> {
let mut uploads = Vec::new();
for bundle in bundles {
match DsymFile::new(bundle, include_source) {
match DsymFile::new(bundle, include_source, source_root) {
Ok(dsym_file) => {
info!(
"Processing dSYM {} (UUIDs: {})",
Expand Down
28 changes: 26 additions & 2 deletions cli/src/debug_symbols/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ pub struct Args {
/// Implies --force unless --skip-on-conflict is set.
#[arg(long, default_value_t = false)]
pub include_source: bool,

/// Limit uploaded source files to this directory. Requires --include-source.
#[arg(long, requires = "include_source")]
pub source_root: Option<PathBuf>,
}

pub fn upload(args: &Args) -> Result<()> {
Expand All @@ -44,8 +48,24 @@ pub fn upload(args: &Args) -> Result<()> {
release,
conflict,
include_source,
source_root,
} = args;
let release_args = release.resolve_info_plist()?;
let source_root = source_root
.as_ref()
.map(|root| -> Result<PathBuf> {
let root = root.canonicalize().map_err(|e| {
anyhow!(
"Source root {} canonicalization failed: {e}",
root.display()
)
})?;
if !root.is_dir() {
anyhow::bail!("Source root {} is not a directory", root.display());
}
Ok(root)
})
.transpose()?;

let directory = directory.canonicalize().map_err(|e| {
anyhow!(
Expand Down Expand Up @@ -80,12 +100,16 @@ pub fn upload(args: &Args) -> Result<()> {
file.path.display(),
file.debug_id
);
native_uploads.push(file.into_upload(None, *include_source)?);
native_uploads.push(file.into_upload(None, *include_source, source_root.as_deref())?);
}

// A failed or oversized dSYM leaves a matching standalone Mach-O as the
// fallback. Successfully packaged, uploadable dSYMs take priority.
let dsym_uploads = package_dsym_bundles(&report.dsym_bundles, *include_source);
let dsym_uploads = package_dsym_bundles(
&report.dsym_bundles,
*include_source,
source_root.as_deref(),
);
let mut uploads = merge_uploads_prefer_dsym(dsym_uploads, native_uploads, MAX_FILE_SIZE);

if uploads.is_empty() {
Expand Down
22 changes: 13 additions & 9 deletions cli/src/dsym/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ impl DsymFile {
/// Each DWARF binary in the bundle gets its own ZIP so that
/// stable UUIDs (e.g. app stubs) don't get a new content hash
/// when a sibling binary (e.g. debug dylib) changes.
pub fn new(path: &PathBuf, include_source: bool) -> Result<Self> {
pub fn new(path: &PathBuf, include_source: bool, source_root: Option<&Path>) -> Result<Self> {
if !path.is_dir() {
anyhow::bail!("Path {} is not a directory", path.display());
}
Expand All @@ -55,7 +55,7 @@ impl DsymFile {
let mut entries = Vec::new();
for (uuid, arch, dwarf_filename) in &uuid_entries {
let dwarf_path = dwarf_dir.join(dwarf_filename);
let data = zip_dwarf_binary(&dwarf_path, arch, include_source)?;
let data = zip_dwarf_binary(&dwarf_path, arch, include_source, source_root)?;
entries.push(DsymEntry {
uuid: uuid.clone(),
data,
Expand Down Expand Up @@ -154,9 +154,13 @@ fn extract_dsym_uuids(dsym_path: &PathBuf) -> Result<Vec<(String, String, String
/// dwarf — the raw DWARF Mach-O binary (single-arch)
/// __source/manifest.json — (optional) source file manifest
/// __source/... — (optional) source files
fn zip_dwarf_binary(dwarf_path: &Path, arch: &str, include_source: bool) -> Result<Vec<u8>> {
fn zip_dwarf_binary(
dwarf_path: &Path,
arch: &str,
include_source: bool,
source_root: Option<&Path>,
) -> Result<Vec<u8>> {
use std::io::{Cursor, Write};
use tracing::info;

let mut buffer = Cursor::new(Vec::new());

Expand All @@ -176,11 +180,11 @@ fn zip_dwarf_binary(dwarf_path: &Path, arch: &str, include_source: bool) -> Resu
if include_source {
match source_bundle::extract_source_paths_from_dwarf(dwarf_path) {
Ok(all_paths) => {
let filtered = source_bundle::filter_source_paths(&all_paths);
info!(
"Found {} source paths in DWARF ({} after filtering)",
all_paths.len(),
filtered.len()
let filtered = source_bundle::filter_source_paths_for_upload(
&all_paths,
source_root,
dwarf_path,
|_| true,
);

if !filtered.is_empty() {
Expand Down
72 changes: 70 additions & 2 deletions cli/src/dsym/source_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,48 @@ pub fn filter_source_paths(paths: &[String]) -> Vec<&str> {
.collect()
}

pub fn filter_source_paths_under_root<'a>(paths: &'a [String], root: &Path) -> Vec<&'a str> {
let Ok(root) = root.canonicalize() else {
return Vec::new();
};
filter_source_paths(paths)
.into_iter()
.filter(|path| {
Path::new(path)
.canonicalize()
.is_ok_and(|resolved| resolved.starts_with(&root))
})
.collect()
}

pub fn filter_source_paths_for_upload<'a>(
paths: &'a [String],
source_root: Option<&Path>,
binary_path: &Path,
extra_filter: impl Fn(&str) -> bool,
) -> Vec<&'a str> {
let filtered = match source_root {
Some(root) => filter_source_paths_under_root(paths, root),
None => filter_source_paths(paths),
};
let filtered: Vec<&str> = filtered
.into_iter()
.filter(|path| extra_filter(path))
.collect();
info!(
"Found {} source paths in DWARF ({} after filtering)",
paths.len(),
filtered.len()
);
if filtered.is_empty() && source_root.is_some() {
warn!(
"No readable sources found inside the source root for {}",
binary_path.display()
);
}
filtered
}

/// Collect source files from disk, reading each file referenced by DWARF debug info.
///
/// Since the CLI runs on the build machine, the absolute paths from DWARF are valid.
Expand Down Expand Up @@ -723,8 +765,8 @@ pub fn load_sources_from_zip(
#[cfg(test)]
mod tests {
use super::{
build_zip_relative_paths, filter_go_toolchain_sources, is_bundleable_go_source,
read_source_file,
build_zip_relative_paths, collect_source_files, filter_go_toolchain_sources,
filter_source_paths_under_root, is_bundleable_go_source, read_source_file,
};

#[test]
Expand Down Expand Up @@ -766,6 +808,32 @@ mod tests {
}
}

#[test]
fn source_root_only_includes_files_inside_the_app() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("app");
let framework = dir.path().join("app-framework");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::create_dir_all(&framework).unwrap();
let source = app.join("lib/main.dart");
let outside = framework.join("main.dart");
std::fs::write(&source, "void main() {}").unwrap();
std::fs::write(&outside, "void main() {}").unwrap();
let paths = vec![
source.to_string_lossy().into_owned(),
outside.to_string_lossy().into_owned(),
app.join("lib/../../app-framework/main.dart")
.to_string_lossy()
.into_owned(),
];

let filtered = filter_source_paths_under_root(&paths, &app);
assert_eq!(filtered, vec![paths[0].as_str()]);
let sources = collect_source_files(&filtered).unwrap();
assert_eq!(sources.manifest.files.len(), 1);
assert_eq!(sources.contents.values().next().unwrap(), b"void main() {}");
}

#[test]
fn symlinked_source_files_are_refused() {
let dir = tempfile::tempdir().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion cli/src/dsym/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ pub fn upload(args: &Args) -> Result<()> {
for dsym_path in dsym_paths {
info!("Processing dSYM: {}", dsym_path.display());

match DsymFile::new(&dsym_path, *include_source) {
match DsymFile::new(&dsym_path, *include_source, None) {
Ok(mut dsym_file) => {
dsym_file.release_id = chunk_release_id.clone();
info!(
Expand Down
Loading
Loading