From 4dd0b988cd4efe0bcef671f78280a63ecfecaeaa Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 4 Aug 2026 18:58:11 -0700 Subject: [PATCH 1/2] feat(symbols): derive the upload endpoint from --base-uri Uploading to any instance other than production took two flags that had to agree: --base-uri to name the instance, and --backend-url to name that same instance's observability API. The second is derivable from the first, since every instance publishes the API under a host named for it, which is how the production default was already built. --backend-url now defaults to the API of whichever instance --base-uri names, so staging is one flag. Only LaunchDarkly's own hosts are derived from: a base URI aimed at a local stack says nothing about where its observability API listens, so those keep the production default and --backend-url still overrides. Co-authored-by: Cursor --- cmd/symbols/upload.go | 36 +++++++++++++++++++++++++++++++++--- cmd/symbols/upload_test.go | 22 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/cmd/symbols/upload.go b/cmd/symbols/upload.go index 980b95e3..be68fb0e 100644 --- a/cmd/symbols/upload.go +++ b/cmd/symbols/upload.go @@ -52,9 +52,19 @@ const ( // no sources. sourcePathFlag = "source-path" - defaultPath = "." + defaultPath = "." + + // defaultBackendUrl is the observability API for LaunchDarkly production, which + // is what defaultBackendURLFor derives for the default base URI. defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" + // Every LaunchDarkly instance publishes the observability API under its own + // host, named for the instance the app is served from: staging's app at + // ld-stg.launchdarkly.com has its API at pri.observability.ld-stg.launchdarkly.com. + // "pri" is the authenticated graph, which is the one that hands out upload URLs. + launchDarklyDomain = "launchdarkly.com" + observabilityAPIPrefix = "pri.observability." + // reactNativeSymbolsIDPrefix is the storage "version" segment for symbols-id // addressed JS maps (Symbols Id Lane). Keys become _sym/js/id//, // matching what the symbolication backend derives from the reported symbols id. @@ -218,7 +228,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error skipExisting := !viper.GetBool(noSkipExistingFlag) if backendUrl == "" { - backendUrl = defaultBackendUrl + backendUrl = defaultBackendURLFor(viper.GetString(cliflags.BaseURIFlag)) } // Apple dSYMs take a dedicated path: they are compiled to per-arch .dsymmap @@ -527,6 +537,26 @@ func readSymbolsIDFile(filePath string) string { // // A backend that predates these arguments rejects the query, so this retries once // without them, keeping an updated CLI working against an older deployment. +// defaultBackendURLFor derives the observability API endpoint from the LaunchDarkly +// base URI, so aiming the CLI at another instance takes the one flag that names the +// instance rather than two flags that have to agree. +// +// Only LaunchDarkly's own hosts are derived from. A base URI pointing at a local or +// proxied stack says nothing about where its observability API listens, so those keep +// the production default and --backend-url stays the way to say otherwise. +func defaultBackendURLFor(baseURI string) string { + parsed, err := url.Parse(strings.TrimSpace(baseURI)) + if err != nil { + return defaultBackendUrl + } + + host := parsed.Hostname() + if host != launchDarklyDomain && !strings.HasSuffix(host, "."+launchDarklyDomain) { + return defaultBackendUrl + } + return "https://" + observabilityAPIPrefix + host +} + func getSymbolUploadUrls(apiKey, projectID string, paths, digests []string, backendUrl string, skipExisting bool) ([]string, error) { urls, err := requestSymbolUploadUrls(apiKey, projectID, paths, digests, backendUrl, skipExisting) if err != nil && skipExisting && mentionsDedupArgument(err) { @@ -712,7 +742,7 @@ func initFlags(cmd *cobra.Command) { cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded symbol files") _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) - cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments") + cmd.Flags().String(backendUrlFlag, "", fmt.Sprintf("An optional backend url for self-hosted deployments. Defaults to the observability API of whichever instance --%s names (%s for the default)", cliflags.BaseURIFlag, defaultBackendUrl)) _ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag)) cmd.Flags().Bool(includeSourcesFlag, false, fmt.Sprintf("Also upload your source files so the errors page can show source context around native frames (%s and %s). Your source is stored in LaunchDarkly", typeAppleDSYM, typeAndroid)) diff --git a/cmd/symbols/upload_test.go b/cmd/symbols/upload_test.go index 04cc87be..6b0cf468 100644 --- a/cmd/symbols/upload_test.go +++ b/cmd/symbols/upload_test.go @@ -34,6 +34,28 @@ func TestNewUploadCmd(t *testing.T) { assert.Equal(t, []string{"true"}, cmd.Flags().Lookup("project").Annotations["required"]) } +// Naming the instance once, with --base-uri, is what should aim an upload at it: the +// observability API of every LaunchDarkly instance is named for that instance, so +// asking for both is asking for two flags that can disagree. +func TestDefaultBackendURLFor(t *testing.T) { + for name, tc := range map[string]struct{ baseURI, want string }{ + "production": {"https://app.launchdarkly.com", defaultBackendUrl}, + "staging": {"https://ld-stg.launchdarkly.com", "https://pri.observability.ld-stg.launchdarkly.com"}, + "trailing slash": {"https://ld-stg.launchdarkly.com/", "https://pri.observability.ld-stg.launchdarkly.com"}, + "surrounding whitespace": {" https://ld-stg.launchdarkly.com ", "https://pri.observability.ld-stg.launchdarkly.com"}, + "regional instance": {"https://app.eu.launchdarkly.com", "https://pri.observability.app.eu.launchdarkly.com"}, + + // Nothing about one of these says where an observability API listens, so the + // production default stands and --backend-url remains how to say otherwise. + "local stack": {"http://localhost:3000", defaultBackendUrl}, + "host that merely ends in the domain name": {"https://notlaunchdarkly.com", defaultBackendUrl}, + "unset": {"", defaultBackendUrl}, + "garbage": {"://", defaultBackendUrl}, + } { + assert.Equal(t, tc.want, defaultBackendURLFor(tc.baseURI), name) + } +} + func TestIsReactNativeUploadFile(t *testing.T) { // React Native iOS bundle + map. assert.True(t, isReactNativeUploadFile("main.jsbundle")) From f9d89ea11c2f359c8dcf88d72522032033e2c49e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 5 Aug 2026 11:22:33 -0700 Subject: [PATCH 2/2] docs(symbols): put each comment above the function it describes defaultBackendURLFor landed inside getSymbolUploadUrls' doc comment, which left that function undocumented and opened the new one with three paragraphs about upload URLs and dedup retries. Co-authored-by: Cursor --- cmd/symbols/upload.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/symbols/upload.go b/cmd/symbols/upload.go index be68fb0e..f5e8fd24 100644 --- a/cmd/symbols/upload.go +++ b/cmd/symbols/upload.go @@ -528,15 +528,6 @@ func readSymbolsIDFile(filePath string) string { return strings.TrimSpace(string(content)) } -// getSymbolUploadUrls returns one upload URL per requested key, in order. -// -// With skipExisting, a key whose bytes the backend already stores comes back empty -// and callers must skip it. digests is parallel to paths and may be nil or hold "" -// for a key the caller has no digest for; it is what lets the backend settle a key -// that isn't derived from its own contents, since there existence proves nothing. -// -// A backend that predates these arguments rejects the query, so this retries once -// without them, keeping an updated CLI working against an older deployment. // defaultBackendURLFor derives the observability API endpoint from the LaunchDarkly // base URI, so aiming the CLI at another instance takes the one flag that names the // instance rather than two flags that have to agree. @@ -557,6 +548,15 @@ func defaultBackendURLFor(baseURI string) string { return "https://" + observabilityAPIPrefix + host } +// getSymbolUploadUrls returns one upload URL per requested key, in order. +// +// With skipExisting, a key whose bytes the backend already stores comes back empty +// and callers must skip it. digests is parallel to paths and may be nil or hold "" +// for a key the caller has no digest for; it is what lets the backend settle a key +// that isn't derived from its own contents, since there existence proves nothing. +// +// A backend that predates these arguments rejects the query, so this retries once +// without them, keeping an updated CLI working against an older deployment. func getSymbolUploadUrls(apiKey, projectID string, paths, digests []string, backendUrl string, skipExisting bool) ([]string, error) { urls, err := requestSymbolUploadUrls(apiKey, projectID, paths, digests, backendUrl, skipExisting) if err != nil && skipExisting && mentionsDedupArgument(err) {