Skip to content

Commit 2158289

Browse files
feat(sourcemaps): Multi-project sourcemaps upload
It is now possible to upload sourcemaps to multiple projects at once, by passing the `-p`/`--project` flag multiple times to the `sentry-cli sourcemaps upload` command. Previously, it was possible to specify multiple projects via this flag, but all but the first project were ignored. Multi-project sourcemaps uploads only work for Sentry servers which support chunked uploads – since this feature was added quite some time ago, we expect most self-hosted Sentry instances will support this. Older versions of Sentry continue to only support single-project uploads, but now, instead of silently ignoring all but the first project, an error is returned. Closes #2408
1 parent 1bb1afc commit 2158289

File tree

9 files changed

+65
-40
lines changed

9 files changed

+65
-40
lines changed

src/api/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,6 +1423,9 @@ impl RegionSpecificApi<'_> {
14231423

14241424
/// Uploads a new release file. The file is loaded directly from the file
14251425
/// system and uploaded as `name`.
1426+
/// If the upload context contains multiple projects, we only upload to the
1427+
/// first project, ignoring the rest. Calling code should therefore ensure
1428+
/// that the upload context contains at most one project.
14261429
pub fn upload_release_file(
14271430
&self,
14281431
context: &LegacyUploadContext,
@@ -1445,6 +1448,7 @@ impl RegionSpecificApi<'_> {
14451448
PathArg(context.release())
14461449
)
14471450
};
1451+
14481452
let mut form = curl::easy::Form::new();
14491453

14501454
let filename = Path::new(name)

src/commands/debug_files/bundle_jvm.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
5757

5858
let context = &UploadContext {
5959
org: &org,
60-
project: project.as_deref(),
60+
projects: &project.into_iter().collect::<Vec<_>>(),
6161
release: None,
6262
dist: None,
6363
note: None,

src/commands/files/upload.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
150150

151151
let context = &UploadContext {
152152
org: &org,
153-
project: project.as_deref(),
153+
projects: &project.into_iter().collect::<Vec<_>>(),
154154
release: Some(&release),
155155
dist,
156156
note: None,

src/commands/react_native/appcenter.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
111111
let config = Config::current();
112112
let here = env::current_dir()?;
113113
let here_str: &str = &here.to_string_lossy();
114-
let (org, project) = config.get_org_and_project(matches)?;
114+
let org = config.get_org(matches)?;
115+
let projects = config.get_projects(matches)?;
115116
let app = matches.get_one::<String>("app_name").unwrap();
116117
let platform = matches.get_one::<String>("platform").unwrap();
117118
let deployment = matches
@@ -189,7 +190,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
189190

190191
processor.upload(&UploadContext {
191192
org: &org,
192-
project: Some(&project),
193+
projects: &projects,
193194
release: Some(&release),
194195
dist: None,
195196
note: None,
@@ -208,7 +209,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
208209

209210
processor.upload(&UploadContext {
210211
org: &org,
211-
project: Some(&project),
212+
projects: &projects,
212213
release: Some(&release),
213214
dist: Some(dist),
214215
note: None,

src/commands/react_native/gradle.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ pub fn make_command(command: Command) -> Command {
7070

7171
pub fn execute(matches: &ArgMatches) -> Result<()> {
7272
let config = Config::current();
73-
let (org, project) = config.get_org_and_project(matches)?;
73+
let org = config.get_org(matches)?;
74+
let projects = config.get_projects(matches)?;
7475
let api = Api::current();
7576
let base = env::current_dir()?;
7677

@@ -123,7 +124,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
123124

124125
processor.upload(&UploadContext {
125126
org: &org,
126-
project: Some(&project),
127+
projects: &projects,
127128
release: Some(version),
128129
dist: Some(dist),
129130
note: None,
@@ -137,7 +138,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
137138
// Debug Id Upload
138139
processor.upload(&UploadContext {
139140
org: &org,
140-
project: Some(&project),
141+
projects: &projects,
141142
release: None,
142143
dist: None,
143144
note: None,

src/commands/react_native/xcode.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
340340
if dist_from_env.is_err() && release_from_env.is_err() && matches.get_flag("no_auto_release") {
341341
processor.upload(&UploadContext {
342342
org: &org,
343-
project: Some(&project),
343+
projects: &[project],
344344
release: None,
345345
dist: None,
346346
note: None,
@@ -376,7 +376,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
376376
None => {
377377
processor.upload(&UploadContext {
378378
org: &org,
379-
project: Some(&project),
379+
projects: &[project],
380380
release: release_name.as_deref(),
381381
dist: dist.as_deref(),
382382
note: None,
@@ -387,10 +387,11 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
387387
})?;
388388
}
389389
Some(dists) => {
390+
let projects = &[project];
390391
for dist in dists {
391392
processor.upload(&UploadContext {
392393
org: &org,
393-
project: Some(&project),
394+
projects,
394395
release: release_name.as_deref(),
395396
dist: Some(dist),
396397
note: None,

src/commands/sourcemaps/upload.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,8 @@ fn process_sources_from_paths(
421421
pub fn execute(matches: &ArgMatches) -> Result<()> {
422422
let config = Config::current();
423423
let version = config.get_release_with_legacy_fallback(matches).ok();
424-
let (org, project) = config.get_org_and_project(matches)?;
424+
let org = config.get_org(matches)?;
425+
let projects = config.get_projects(matches)?;
425426
let api = Api::current();
426427
let mut processor = SourceMapProcessor::new();
427428
let mut chunk_upload_options = api.authenticated()?.get_chunk_upload_options(&org)?;
@@ -450,7 +451,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
450451
let max_wait = wait_for_secs.map_or(DEFAULT_MAX_WAIT, Duration::from_secs);
451452
let upload_context = UploadContext {
452453
org: &org,
453-
project: Some(&project),
454+
projects: &projects,
454455
release: version.as_deref(),
455456
dist: matches.get_one::<String>("dist").map(String::as_str),
456457
note: matches.get_one::<String>("note").map(String::as_str),

src/utils/file_upload.rs

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub fn initialize_legacy_release_upload(context: &UploadContext) -> Result<()> {
4242
// need to do anything here. Artifact bundles will also only work
4343
// if a project is provided which is technically unnecessary for the
4444
// legacy upload though it will unlikely to be what users want.
45-
if context.project.is_some()
45+
if !context.projects.is_empty()
4646
&& context.chunk_upload_options.is_some_and(|x| {
4747
x.supports(ChunkUploadCapability::ArtifactBundles)
4848
|| x.supports(ChunkUploadCapability::ArtifactBundlesV2)
@@ -52,7 +52,7 @@ pub fn initialize_legacy_release_upload(context: &UploadContext) -> Result<()> {
5252
}
5353

5454
// TODO: make this into an error later down the road
55-
if context.project.is_none() {
55+
if context.projects.is_empty() {
5656
eprintln!(
5757
"{}",
5858
style(
@@ -71,7 +71,7 @@ pub fn initialize_legacy_release_upload(context: &UploadContext) -> Result<()> {
7171
context.org,
7272
&NewRelease {
7373
version: version.to_string(),
74-
projects: context.project.map(|x| x.to_string()).into_iter().collect(),
74+
projects: context.projects.to_vec(),
7575
..Default::default()
7676
},
7777
)?;
@@ -84,7 +84,7 @@ pub fn initialize_legacy_release_upload(context: &UploadContext) -> Result<()> {
8484
#[derive(Debug, Clone)]
8585
pub struct UploadContext<'a> {
8686
pub org: &'a str,
87-
pub project: Option<&'a str>,
87+
pub projects: &'a [String],
8888
pub release: Option<&'a str>,
8989
pub dist: Option<&'a str>,
9090
pub note: Option<&'a str>,
@@ -105,6 +105,8 @@ impl UploadContext<'_> {
105105
pub enum LegacyUploadContextError {
106106
#[error("a release is required for this upload")]
107107
ReleaseMissing,
108+
#[error("only a single project is supported for this upload")]
109+
ProjectMultiple,
108110
}
109111

110112
/// Represents the context for legacy release uploads.
@@ -182,7 +184,7 @@ impl<'a> TryFrom<&'a UploadContext<'_>> for LegacyUploadContext<'a> {
182184
fn try_from(value: &'a UploadContext) -> Result<Self, Self::Error> {
183185
let &UploadContext {
184186
org,
185-
project,
187+
projects,
186188
release,
187189
dist,
188190
note: _,
@@ -192,6 +194,12 @@ impl<'a> TryFrom<&'a UploadContext<'_>> for LegacyUploadContext<'a> {
192194
chunk_upload_options: _,
193195
} = value;
194196

197+
let project = match projects {
198+
[] => None,
199+
[project] => Some(project.as_str()),
200+
[_, _, ..] => Err(LegacyUploadContextError::ProjectMultiple)?,
201+
};
202+
195203
let release = release.ok_or(LegacyUploadContextError::ReleaseMissing)?;
196204

197205
Ok(Self {
@@ -296,14 +304,23 @@ impl<'a> FileUpload<'a> {
296304
}
297305

298306
pub fn upload(&self) -> Result<()> {
307+
// multiple projects OK
299308
initialize_legacy_release_upload(self.context)?;
300309

301310
if let Some(chunk_options) = self.context.chunk_upload_options {
302311
if chunk_options.supports(ChunkUploadCapability::ReleaseFiles) {
312+
// multiple projects OK
303313
return upload_files_chunked(self.context, &self.files, chunk_options);
304314
}
305315
}
306316

317+
log::warn!(
318+
"Your Sentry server does not support chunked uploads. \
319+
We are falling back to a legacy upload method, which \
320+
has fewer features and is less reliable. Please consider \
321+
upgrading your Sentry server or switching to our SaaS offering."
322+
);
323+
307324
// Do not permit uploads of more than 20k files if the server does not
308325
// support artifact bundles. This is a temporary downside protection to
309326
// protect users from uploading more sources than we support.
@@ -322,10 +339,12 @@ impl<'a> FileUpload<'a> {
322339
let legacy_context = &self.context.try_into().map_err(|e| {
323340
anyhow::anyhow!(
324341
"Error while performing legacy upload: {e}. \
325-
If you would like to upload files {}, you need to upgrade your Sentry server \
326-
or switch to our SaaS offering.",
342+
If you would like to upload files {}, you need to upgrade your Sentry server \
343+
or switch to our SaaS offering.",
327344
match e {
328345
LegacyUploadContextError::ReleaseMissing => "without specifying a release",
346+
LegacyUploadContextError::ProjectMultiple =>
347+
"to multiple projects simultaneously",
329348
}
330349
)
331350
})?;
@@ -452,13 +471,13 @@ fn poll_assemble(
452471
let authenticated_api = api.authenticated()?;
453472
let use_artifact_bundle = (options.supports(ChunkUploadCapability::ArtifactBundles)
454473
|| options.supports(ChunkUploadCapability::ArtifactBundlesV2))
455-
&& context.project.is_some();
474+
&& !context.projects.is_empty();
456475
let response = loop {
457476
// prefer standalone artifact bundle upload over legacy release based upload
458477
let response = if use_artifact_bundle {
459478
authenticated_api.assemble_artifact_bundle(
460479
context.org,
461-
&[context.project.unwrap().to_string()],
480+
context.projects,
462481
checksum,
463482
chunks,
464483
context.release,
@@ -544,11 +563,11 @@ fn upload_files_chunked(
544563

545564
// Filter out chunks that are already on the server. This only matters if the server supports
546565
// `ArtifactBundlesV2`, otherwise the `missing_chunks` field is meaningless.
547-
if options.supports(ChunkUploadCapability::ArtifactBundlesV2) && context.project.is_some() {
566+
if options.supports(ChunkUploadCapability::ArtifactBundlesV2) && !context.projects.is_empty() {
548567
let api = Api::current();
549568
let response = api.authenticated()?.assemble_artifact_bundle(
550569
context.org,
551-
&[context.project.unwrap().to_string()],
570+
context.projects,
552571
checksum,
553572
&checksums,
554573
context.release,
@@ -615,8 +634,9 @@ fn build_artifact_bundle(
615634
}
616635

617636
bundle.set_attribute("org".to_owned(), context.org.to_owned());
618-
if let Some(project) = context.project {
619-
bundle.set_attribute("project".to_owned(), project.to_owned());
637+
if let [project] = context.projects {
638+
// Only set project if there is exactly one project
639+
bundle.set_attribute("project".to_owned(), project);
620640
}
621641
if let Some(release) = context.release {
622642
bundle.set_attribute("release".to_owned(), release.to_owned());
@@ -707,8 +727,8 @@ fn print_upload_context_details(context: &UploadContext) {
707727
);
708728
println!(
709729
"{} {}",
710-
style("> Project:").dim(),
711-
style(context.project.unwrap_or("None")).yellow()
730+
style("> Projects:").dim(),
731+
style(context.projects.join(", ")).yellow()
712732
);
713733
println!(
714734
"{} {}",
@@ -772,7 +792,7 @@ mod tests {
772792
fn build_artifact_bundle_deterministic() {
773793
let context = UploadContext {
774794
org: "wat-org",
775-
project: Some("wat-project"),
795+
projects: &["wat-project".into()],
776796
release: None,
777797
dist: None,
778798
note: None,

src/utils/sourcemaps.rs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -769,13 +769,16 @@ impl SourceMapProcessor {
769769
fn flag_uploaded_sources(&mut self, context: &UploadContext<'_>) -> usize {
770770
let mut files_needing_upload = self.sources.len();
771771

772-
// TODO: this endpoint does not exist for non release based uploads
773772
if !context.dedupe {
774773
return files_needing_upload;
775774
}
776-
let release = match context.release {
777-
Some(release) => release,
778-
None => return files_needing_upload,
775+
776+
// This endpoint only supports at most one project, and a release is required.
777+
// If the upload contains multiple projects or no release, we do not use deduplication.
778+
let (project, release) = match (context.projects, context.release) {
779+
([project], Some(release)) => (Some(project.as_str()), release),
780+
([], Some(release)) => (None, release),
781+
([..], Some(_) | None) => return files_needing_upload,
779782
};
780783

781784
let mut sources_checksums: Vec<_> = self
@@ -790,12 +793,7 @@ impl SourceMapProcessor {
790793
let api = Api::current();
791794

792795
if let Ok(artifacts) = api.authenticated().and_then(|api| {
793-
api.list_release_files_by_checksum(
794-
context.org,
795-
context.project,
796-
release,
797-
&sources_checksums,
798-
)
796+
api.list_release_files_by_checksum(context.org, project, release, &sources_checksums)
799797
}) {
800798
let already_uploaded_checksums: HashSet<_> = artifacts
801799
.into_iter()
@@ -852,7 +850,6 @@ impl SourceMapProcessor {
852850
}
853851
}
854852
}
855-
856853
let files_needing_upload = self.flag_uploaded_sources(context);
857854
if files_needing_upload > 0 {
858855
let mut uploader = FileUpload::new(context);

0 commit comments

Comments
 (0)