Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
95876c4
feat(preprod): Add snapshots subcommand
noahsmartin Dec 18, 2025
e45f588
update deps
lcian Jan 27, 2026
f3cfadb
no multiple projects, get rid of shard index
lcian Jan 27, 2026
4794046
add organization details API util
lcian Jan 27, 2026
c6e67e9
introduce necessary utils
lcian Jan 27, 2026
1cd38b7
use batch API
lcian Jan 28, 2026
15a89af
improve
lcian Jan 28, 2026
1bc440d
improve
lcian Jan 28, 2026
3417b53
cargo clippy --fix
lcian Jan 28, 2026
3f206dd
improve
lcian Jan 28, 2026
e902bfc
fmt all
lcian Jan 28, 2026
e5bd2d7
fix test
lcian Jan 28, 2026
081e550
add auth, fix url
lcian Feb 5, 2026
d13ddec
cargo clippy --fix
lcian Feb 5, 2026
db855ee
changelog
lcian Feb 5, 2026
b70e7b1
Merge branch 'master' into lcian/feat/snapshots
lcian Feb 5, 2026
0d59c90
Updates to get e2e run working
rbro112 Feb 10, 2026
46b3edc
Feedback
rbro112 Feb 14, 2026
56e0050
Add retention policy
rbro112 Feb 14, 2026
e5f1f08
ref(api): Use rename_all on OrganizationLinks
lcian Feb 16, 2026
4a3da60
ref(utils): Move objectstore module from directory to single file
lcian Feb 16, 2026
07c3b7a
ref(utils): Accept AuthenticatedApi in get_objectstore_url
lcian Feb 16, 2026
3f4c1ff
ref(api): Make PathArg public and use it in objectstore URL
lcian Feb 16, 2026
4596f8d
fix(snapshots): Handle invalid auth token header gracefully
lcian Feb 16, 2026
b0fc8b0
use objectstore_client with native-tls feature
lcian Feb 16, 2026
567b86d
improve
lcian Feb 20, 2026
f2516bb
improve
lcian Feb 20, 2026
7bd56f6
improve
lcian Feb 20, 2026
e3c3255
Replace image sizing utils with imagesize crate
rbro112 Feb 20, 2026
6fc8ede
Update naming to match upcoming backend change
rbro112 Feb 23, 2026
3008c50
regenerate Cargo.lock
lcian Feb 24, 2026
1751bb9
remove dead code
lcian Feb 24, 2026
c482c2d
unnecessary pub
lcian Feb 24, 2026
688de63
fix(api): Remove redundant /api/0/ prefix from snapshots upload optio…
lcian Feb 24, 2026
a5148fd
fix(snapshots): Fix TOCTOU by reading each file once at upload time
lcian Feb 24, 2026
2d98930
style(snapshots): Run cargo fmt
lcian Feb 24, 2026
5c39a7b
fix(snapshots): Address clippy warnings
lcian Feb 24, 2026
b297024
improve
lcian Feb 25, 2026
815866a
improve
lcian Feb 25, 2026
043ee62
improve
lcian Feb 25, 2026
8543f82
improve
lcian Feb 25, 2026
be90166
imporst sort
lcian Feb 25, 2026
8528b95
improve
lcian Feb 25, 2026
6c49f65
hidden files
lcian Feb 26, 2026
8ac78c1
fix for hidden root dir
lcian Feb 26, 2026
4522f68
use published objectstore, sort deps
lcian Feb 27, 2026
3195958
revert unrelated change
lcian Feb 27, 2026
ca0a938
pathbuf
lcian Feb 27, 2026
5e451bc
Merge branch 'master' into lcian/feat/snapshots
lcian Feb 27, 2026
12f0de3
changelog
lcian Feb 27, 2026
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
1,188 changes: 1,169 additions & 19 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ java-properties = "2.0.0"
lazy_static = "1.4.0"
libc = "0.2.139"
log = { version = "0.4.17", features = ["std"] }
objectstore-client = { git = "https://github.com/getsentry/objectstore.git", branch = "lcian/feat/rust-batch-client" }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: Before we merge these changes, we should ensure we are depending on a version that's been published to crates.io, not a Git branch.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lcian will let you take this one as it's more objectstore related

open = "3.2.0"
parking_lot = "0.12.1"
percent-encoding = "2.2.0"
Expand All @@ -62,6 +63,7 @@ sentry = { version = "0.46.0", default-features = false, features = [
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"
sha1_smol = { version = "1.0.0", features = ["serde", "std"] }
sha2 = "0.10.9"
sourcemap = { version = "9.3.0", features = ["ram_bundle"] }
symbolic = { version = "12.13.3", features = ["debuginfo-serde", "il2cpp"] }
thiserror = "1.0.38"
Expand All @@ -77,6 +79,7 @@ chrono-tz = "0.8.4"
secrecy = "0.8.0"
lru = "0.16.0"
backon = { version = "1.5.2", features = ["std", "std-blocking-sleep"] }
tokio = { version = "1.47", features = ["rt", "rt-multi-thread"] }

[dev-dependencies]
assert_cmd = "2.0.11"
Expand Down
19 changes: 19 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,13 @@ impl AuthenticatedApi<'_> {
}
Ok(rv)
}

/// Fetch organization details
pub fn fetch_organization_details(&self, org: &str) -> ApiResult<OrganizationDetails> {
let path = format!("/api/0/organizations/{}/", PathArg(org));
self.get(&path)?
.convert_rnf(ApiErrorKind::OrganizationNotFound)
}
}

/// Available datasets for fetching organization events
Expand Down Expand Up @@ -1761,6 +1768,18 @@ pub struct Organization {
pub features: Vec<String>,
}

#[derive(Deserialize, Debug)]
pub struct OrganizationLinks {
#[serde(rename = "regionUrl")]
pub region_url: String,
}

#[derive(Deserialize, Debug)]
pub struct OrganizationDetails {
pub id: String,
pub links: OrganizationLinks,
}

#[derive(Deserialize, Debug)]
pub struct Team {
#[expect(dead_code)]
Expand Down
2 changes: 2 additions & 0 deletions src/commands/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ use clap::{ArgMatches, Command};

use crate::utils::args::ArgExt as _;

pub mod snapshots;
pub mod upload;

macro_rules! each_subcommand {
($mac:ident) => {
$mac!(snapshots);
$mac!(upload);
};
}
Expand Down
210 changes: 210 additions & 0 deletions src/commands/build/snapshots.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
use std::fs;
use std::path::Path;

use anyhow::{Context as _, Result};
use clap::{Arg, ArgMatches, Command};
use console::style;
use log::{debug, info};
use objectstore_client::{Client, Usecase};
use sha2::{Digest as _, Sha256};
use walkdir::WalkDir;

use crate::api::Api;
use crate::config::Config;
use crate::utils::api::get_org_project_id;
use crate::utils::args::ArgExt as _;
use crate::utils::objectstore::get_objectstore_url;

const EXPERIMENTAL_WARNING: &str =
"[EXPERIMENTAL] The \"build snapshots\" command is experimental. \
The command is subject to breaking changes, including removal, in any Sentry CLI release.";

pub fn make_command(command: Command) -> Command {
command
.about("[EXPERIMENTAL] Upload build snapshots to a project.")
.long_about(format!(
"Upload build snapshots to a project.\n\n{EXPERIMENTAL_WARNING}"
))
.org_arg()
.project_arg(false)
.arg(
Arg::new("path")
.value_name("PATH")
.help("The path to the folder containing build snapshots.")
.required(true),
)
.arg(
Arg::new("snapshot_id")
.long("snapshot-id")
.value_name("ID")
.help("The snapshot identifier to associate with the upload.")
.required(true),
)
}

pub fn execute(matches: &ArgMatches) -> Result<()> {
eprintln!("{EXPERIMENTAL_WARNING}");

let config = Config::current();
let org = config.get_org(matches)?;
let project = config.get_project(matches)?;

let path = matches
.get_one::<String>("path")
.expect("path argument is required");
let snapshot_id = matches
.get_one::<String>("snapshot_id")
.expect("snapshot_id argument is required");

info!("Processing build snapshots from: {path}");
info!("Using snapshot ID: {snapshot_id}");
info!("Organization: {org}");
info!("Project: {project}");

// Collect files to upload
let files = collect_files(Path::new(path))?;

if files.is_empty() {
println!("{} No files found to upload", style("!").yellow());
return Ok(());
}

println!(
"{} Found {} {} to upload",
style(">").dim(),
style(files.len()).yellow(),
if files.len() == 1 { "file" } else { "files" }
);

// Upload files using objectstore client
upload_files(&files, &org, &project, snapshot_id)?;

Ok(())
}

fn collect_files(path: &Path) -> Result<Vec<std::path::PathBuf>> {
if !path.exists() {
anyhow::bail!("Path does not exist: {}", path.display());
}

let mut files = Vec::new();

if path.is_file() {
// Only add if not hidden
if !is_hidden_file(path) {
files.push(path.to_path_buf());
}
} else if path.is_dir() {
for entry in WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(Result::ok)
{
if entry.metadata()?.is_file() {
let entry_path = entry.path();
// Skip hidden files
if !is_hidden_file(entry_path) {
files.push(entry_path.to_path_buf());
}
}
}
} else {
anyhow::bail!("Path is neither a file nor directory: {}", path.display());
}

Ok(files)
}

fn is_hidden_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with('.'))
.unwrap_or(false)
}

fn is_json_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("json"))
.unwrap_or(false)
}

fn upload_files(
files: &[std::path::PathBuf],
org: &str,
project: &str,
snapshot_id: &str,
) -> Result<()> {
let url = get_objectstore_url(Api::current(), org)?;
let client = Client::new(url)?;

let (org, project) = get_org_project_id(Api::current(), org, project)?;
let session = Usecase::new("preprod")
.for_project(org, project)
.session(&client)?;

let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("Failed to create tokio runtime")?;

let mut many_builder = session.many();

for file_path in files {
debug!("Processing file: {}", file_path.display());

let contents = fs::read(file_path)
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;

let key = if is_json_file(file_path) {
// For JSON files, use {org}/{snapshotId}/{filename}
let filename = file_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("unknown.json");
format!("{org}/{snapshot_id}/{filename}")
} else {
// For other files, use {org}/{project}/{hash}
let hash = compute_sha256_hash(&contents);
format!("{org}/{project}/{hash}")
};

info!("Queueing {} as {key}", file_path.display());

many_builder = many_builder.push(session.put(contents).key(&key));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate image content overwrites manifest entries

Medium Severity

manifest_entries is keyed by the SHA-256 hash, so two different files with identical contents will overwrite each other’s metadata while image_count still counts both. This can silently drop images from the manifest and misreport how many were uploaded/associated with the snapshot.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this could've been an issue from the get go. Leaving it to @rbro112 to address this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lcian Please consider making an issue so this comment doesn't get lost after we merge the PR


let upload = runtime
.block_on(async { many_builder.send().await })
.context("Failed to upload files")?;

match upload.error_for_failures() {
Ok(()) => {
println!(
"{} Uploaded {} {}",
style(">").dim(),
style(files.len()).yellow(),
if files.len() == 1 { "file" } else { "files" }
);
Ok(())
}
Err(errors) => {
eprintln!("There were errors uploading files:");
for error in &errors {
eprintln!(" {}", style(error).red());
}
anyhow::bail!(
"Failed to upload {} out of {} files",
errors.len(),
files.len()
)
}
}
}

fn compute_sha256_hash(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
format!("{result:x}")
}
41 changes: 41 additions & 0 deletions src/utils/api/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Utilities that rely on the Sentry API.

use crate::api::{Api, AuthenticatedApi};
use anyhow::{Context, Result};

/// Given an org and project slugs or IDs, returns the IDs of both.
pub fn get_org_project_id(api: impl AsRef<Api>, org: &str, project: &str) -> Result<(u64, u64)> {
let authenticated_api = api.as_ref().authenticated()?;
let org_id = get_org_id(authenticated_api, org)?;
let authenticated_api = api.as_ref().authenticated()?;
let project_id = get_project_id(authenticated_api, org, project)?;
Ok((org_id, project_id))
}

/// Given an org slug or ID, returns its ID as a number.
fn get_org_id(api: AuthenticatedApi<'_>, org: &str) -> Result<u64> {
if let Ok(id) = org.parse::<u64>() {
return Ok(id);
}
let details = api.fetch_organization_details(org)?;
Ok(details
.id
.parse::<u64>()
.context("Unable to parse org id")?)
}

/// Given an org and project slugs or IDs, returns the project ID.
fn get_project_id(api: AuthenticatedApi<'_>, org: &str, project: &str) -> Result<u64> {
if let Ok(id) = project.parse::<u64>() {
return Ok(id);
}

let projects = api.list_organization_projects(org)?;
for p in projects {
if p.slug == project {
return p.id.parse::<u64>().context("Unable to parse project id");
}
}

anyhow::bail!("Project not found")
}
2 changes: 2 additions & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Various utility functionality.
pub mod android;
pub mod api;
pub mod args;
pub mod auth_token;
pub mod build;
Expand All @@ -16,6 +17,7 @@ pub mod fs;
pub mod http;
pub mod logging;
pub mod non_empty;
pub mod objectstore;
pub mod progress;
pub mod proguard;
pub mod releases;
Expand Down
11 changes: 11 additions & 0 deletions src/utils/objectstore/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! Utilities to work with the Objectstore service.

use anyhow::Result;

use crate::api::Api;

pub fn get_objectstore_url(api: impl AsRef<Api>, org: &str) -> Result<String> {
let api = api.as_ref().authenticated()?;
let base = api.fetch_organization_details(org)?.links.region_url;
Ok(format!("{base}/api/0/objectstore"))
}
5 changes: 3 additions & 2 deletions tests/integration/_cases/build/build-help.trycmd
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ Manage builds.
Usage: sentry-cli[EXE] build [OPTIONS] <COMMAND>

Commands:
upload Upload builds to a project.
help Print this message or the help of the given subcommand(s)
snapshots [EXPERIMENTAL] Upload build snapshots to a project.
upload Upload builds to a project.
help Print this message or the help of the given subcommand(s)

Options:
-o, --org <ORG> The organization ID or slug.
Expand Down
Loading
Loading