Skip to content

Commit f5ea48c

Browse files
feat(sourcemaps): Multi-project sourcemaps upload (#2497)
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 f540dc1 commit f5ea48c

19 files changed

+72
-50
lines changed

src/api/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,7 @@ impl RegionSpecificApi<'_> {
14451445
PathArg(context.release())
14461446
)
14471447
};
1448+
14481449
let mut form = curl::easy::Form::new();
14491450

14501451
let filename = Path::new(name)

src/commands/debug_files/bundle_jvm.rs

+1-1
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

+1-1
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

+4-3
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

+4-3
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

+4-3
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

+3-2
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

+36-16
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,12 +184,18 @@ 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
..
189191
} = value;
190192

193+
let project = match projects {
194+
[] => None,
195+
[project] => Some(project.as_str()),
196+
[_, _, ..] => Err(LegacyUploadContextError::ProjectMultiple)?,
197+
};
198+
191199
let release = release.ok_or(LegacyUploadContextError::ReleaseMissing)?;
192200

193201
Ok(Self {
@@ -292,14 +300,23 @@ impl<'a> FileUpload<'a> {
292300
}
293301

294302
pub fn upload(&self) -> Result<()> {
303+
// multiple projects OK
295304
initialize_legacy_release_upload(self.context)?;
296305

297306
if let Some(chunk_options) = self.context.chunk_upload_options {
298307
if chunk_options.supports(ChunkUploadCapability::ReleaseFiles) {
308+
// multiple projects OK
299309
return upload_files_chunked(self.context, &self.files, chunk_options);
300310
}
301311
}
302312

313+
log::warn!(
314+
"Your Sentry server does not support chunked uploads. \
315+
We are falling back to a legacy upload method, which \
316+
has fewer features and is less reliable. Please consider \
317+
upgrading your Sentry server or switching to our SaaS offering."
318+
);
319+
303320
// Do not permit uploads of more than 20k files if the server does not
304321
// support artifact bundles. This is a temporary downside protection to
305322
// protect users from uploading more sources than we support.
@@ -318,10 +335,12 @@ impl<'a> FileUpload<'a> {
318335
let legacy_context = &self.context.try_into().map_err(|e| {
319336
anyhow::anyhow!(
320337
"Error while performing legacy upload: {e}. \
321-
If you would like to upload files {}, you need to upgrade your Sentry server \
322-
or switch to our SaaS offering.",
338+
If you would like to upload files {}, you need to upgrade your Sentry server \
339+
or switch to our SaaS offering.",
323340
match e {
324341
LegacyUploadContextError::ReleaseMissing => "without specifying a release",
342+
LegacyUploadContextError::ProjectMultiple =>
343+
"to multiple projects simultaneously",
325344
}
326345
)
327346
})?;
@@ -448,13 +467,13 @@ fn poll_assemble(
448467
let authenticated_api = api.authenticated()?;
449468
let use_artifact_bundle = (options.supports(ChunkUploadCapability::ArtifactBundles)
450469
|| options.supports(ChunkUploadCapability::ArtifactBundlesV2))
451-
&& context.project.is_some();
470+
&& !context.projects.is_empty();
452471
let response = loop {
453472
// prefer standalone artifact bundle upload over legacy release based upload
454473
let response = if use_artifact_bundle {
455474
authenticated_api.assemble_artifact_bundle(
456475
context.org,
457-
&[context.project.unwrap().to_string()],
476+
context.projects,
458477
checksum,
459478
chunks,
460479
context.release,
@@ -540,11 +559,11 @@ fn upload_files_chunked(
540559

541560
// Filter out chunks that are already on the server. This only matters if the server supports
542561
// `ArtifactBundlesV2`, otherwise the `missing_chunks` field is meaningless.
543-
if options.supports(ChunkUploadCapability::ArtifactBundlesV2) && context.project.is_some() {
562+
if options.supports(ChunkUploadCapability::ArtifactBundlesV2) && !context.projects.is_empty() {
544563
let api = Api::current();
545564
let response = api.authenticated()?.assemble_artifact_bundle(
546565
context.org,
547-
&[context.project.unwrap().to_string()],
566+
context.projects,
548567
checksum,
549568
&checksums,
550569
context.release,
@@ -611,8 +630,9 @@ fn build_artifact_bundle(
611630
}
612631

613632
bundle.set_attribute("org".to_owned(), context.org.to_owned());
614-
if let Some(project) = context.project {
615-
bundle.set_attribute("project".to_owned(), project.to_owned());
633+
if let [project] = context.projects {
634+
// Only set project if there is exactly one project
635+
bundle.set_attribute("project".to_owned(), project);
616636
}
617637
if let Some(release) = context.release {
618638
bundle.set_attribute("release".to_owned(), release.to_owned());
@@ -703,8 +723,8 @@ fn print_upload_context_details(context: &UploadContext) {
703723
);
704724
println!(
705725
"{} {}",
706-
style("> Project:").dim(),
707-
style(context.project.unwrap_or("None")).yellow()
726+
style("> Projects:").dim(),
727+
style(context.projects.join(", ")).yellow()
708728
);
709729
println!(
710730
"{} {}",
@@ -768,7 +788,7 @@ mod tests {
768788
fn build_artifact_bundle_deterministic() {
769789
let context = UploadContext {
770790
org: "wat-org",
771-
project: Some("wat-project"),
791+
projects: &["wat-project".into()],
772792
release: None,
773793
dist: None,
774794
note: None,

src/utils/sourcemaps.rs

+8-11
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+
_ => 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);

tests/integration/_cases/react_native/xcode-upload-source-maps-release_and_dist_from_env.trycmd

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Processing react-native sourcemaps for Sentry upload.
1515
> Uploaded files to Sentry
1616
> File upload complete (processing pending on server)
1717
> Organization: wat-org
18-
> Project: wat-project
18+
> Projects: wat-project
1919
> Release: test-release
2020
> Dist: test-dist
2121
> Upload type: artifact bundle

tests/integration/_cases/sourcemaps/sourcemaps-upload-debugid-alias.trycmd

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ $ sentry-cli sourcemaps upload tests/integration/_fixtures/upload_debugid_alias
1010
> Uploaded files to Sentry
1111
> File upload complete (processing pending on server)
1212
> Organization: wat-org
13-
> Project: wat-project
13+
> Projects: wat-project
1414
> Release: None
1515
> Dist: None
1616
> Upload type: artifact bundle

tests/integration/_cases/sourcemaps/sourcemaps-upload-file-hermes-bundle-reference-debug-id.trycmd

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ $ sentry-cli sourcemaps upload --bundle tests/integration/_fixtures/file-hermes-
1010
> Uploaded files to Sentry
1111
> File upload complete (processing pending on server)
1212
> Organization: wat-org
13-
> Project: wat-project
13+
> Projects: wat-project
1414
> Release: None
1515
> Dist: None
1616
> Upload type: artifact bundle

tests/integration/_cases/sourcemaps/sourcemaps-upload-file-ram-bundle.trycmd

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ $ sentry-cli sourcemaps upload --bundle tests/integration/_fixtures/file-ram-bun
99
> Uploaded files to Sentry
1010
> File upload complete (processing pending on server)
1111
> Organization: wat-org
12-
> Project: wat-project
12+
> Projects: wat-project
1313
> Release: wat-release
1414
> Dist: None
1515
> Upload type: artifact bundle

tests/integration/_cases/sourcemaps/sourcemaps-upload-indexed-ram-bundle.trycmd

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ $ sentry-cli sourcemaps upload --bundle tests/integration/_fixtures/indexed-ram-
99
> Uploaded files to Sentry
1010
> File upload complete (processing pending on server)
1111
> Organization: wat-org
12-
> Project: wat-project
12+
> Projects: wat-project
1313
> Release: wat-release
1414
> Dist: None
1515
> Upload type: artifact bundle

tests/integration/_cases/sourcemaps/sourcemaps-upload-modern.trycmd

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ $ sentry-cli sourcemaps upload tests/integration/_fixtures/bundle.min.js.map tes
1111
> Uploaded files to Sentry
1212
> File upload complete (processing pending on server)
1313
> Organization: wat-org
14-
> Project: wat-project
14+
> Projects: wat-project
1515
> Release: None
1616
> Dist: None
1717
> Upload type: artifact bundle

0 commit comments

Comments
 (0)