Skip to content

Commit 398409c

Browse files
feat: Add command for uploading frontend sourcemaps [OB-143] (#531)
Adds new subcommand `ldcli sourcemaps upload --access-token=xxx --project=yyy` for uploading web sourcemaps for use with LaunchDarkly observability. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent 550457c commit 398409c

File tree

5 files changed

+609
-0
lines changed

5 files changed

+609
-0
lines changed

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
logincmd "github.com/launchdarkly/ldcli/cmd/login"
2323
memberscmd "github.com/launchdarkly/ldcli/cmd/members"
2424
resourcecmd "github.com/launchdarkly/ldcli/cmd/resources"
25+
sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps"
2526
"github.com/launchdarkly/ldcli/internal/analytics"
2627
"github.com/launchdarkly/ldcli/internal/config"
2728
"github.com/launchdarkly/ldcli/internal/dev_server"
@@ -203,6 +204,7 @@ func NewRootCommand(
203204
cmd.AddCommand(logincmd.NewLoginCmd(resources.NewClient(version)))
204205
cmd.AddCommand(resourcecmd.NewResourcesCmd())
205206
cmd.AddCommand(devcmd.NewDevServerCmd(resources.NewClient(version), analyticsTrackerFn, dev_server.NewClient(version)))
207+
cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd())
206208
resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn)
207209

208210
// add non-generated commands

cmd/sourcemaps/sourcemaps.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package sourcemaps
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/launchdarkly/ldcli/internal/resources"
7+
)
8+
9+
func NewSourcemapsCmd() *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "sourcemaps",
12+
Short: "Manage sourcemaps",
13+
Long: "Manage sourcemaps for LaunchDarkly error monitoring",
14+
Run: func(cmd *cobra.Command, args []string) {
15+
_ = cmd.Help()
16+
},
17+
}
18+
19+
cmd.AddCommand(NewUploadCmd(resources.NewClient("")))
20+
21+
return cmd
22+
}

cmd/sourcemaps/upload.go

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
package sourcemaps
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"io/fs"
9+
"net/http"
10+
"net/url"
11+
"os"
12+
"path/filepath"
13+
"regexp"
14+
"strings"
15+
16+
"github.com/spf13/cobra"
17+
"github.com/spf13/viper"
18+
19+
"github.com/launchdarkly/ldcli/cmd/cliflags"
20+
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
21+
"github.com/launchdarkly/ldcli/cmd/validators"
22+
"github.com/launchdarkly/ldcli/internal/output"
23+
"github.com/launchdarkly/ldcli/internal/resources"
24+
)
25+
26+
const (
27+
appVersionFlag = "app-version"
28+
pathFlag = "path"
29+
basePathFlag = "base-path"
30+
backendUrlFlag = "backend-url"
31+
32+
defaultPath = "."
33+
defaultBackendUrl = "https://pri.observability.app.launchdarkly.com"
34+
35+
getSourceMapUrlsQuery = `
36+
query GetSourceMapUploadUrls($api_key: String!, $project_id: String!, $paths: [String!]!) {
37+
get_source_map_upload_urls_ld(
38+
api_key: $api_key
39+
project_id: $project_id
40+
paths: $paths
41+
)
42+
}
43+
`
44+
)
45+
46+
type ApiKeyResponse struct {
47+
Data struct {
48+
Credential struct {
49+
ProjectID string `json:"project_id"`
50+
APIKey string `json:"api_key"`
51+
} `json:"ld_credential"`
52+
} `json:"data"`
53+
Errors []struct {
54+
Message string `json:"message"`
55+
} `json:"errors"`
56+
}
57+
58+
type SourceMapUrlsResponse struct {
59+
Data struct {
60+
GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls_ld"`
61+
} `json:"data"`
62+
}
63+
64+
type SourceMapFile struct {
65+
Path string
66+
Name string
67+
}
68+
69+
func NewUploadCmd(client resources.Client) *cobra.Command {
70+
cmd := &cobra.Command{
71+
Args: validators.Validate(),
72+
Use: "upload",
73+
Short: "Upload sourcemaps",
74+
Long: "Upload JavaScript sourcemaps to LaunchDarkly for error monitoring",
75+
RunE: runE(client),
76+
}
77+
78+
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
79+
initFlags(cmd)
80+
81+
return cmd
82+
}
83+
84+
func runE(client resources.Client) func(cmd *cobra.Command, args []string) error {
85+
return func(cmd *cobra.Command, args []string) error {
86+
projectKey := viper.GetString(cliflags.ProjectFlag)
87+
u, _ := url.JoinPath(
88+
viper.GetString(cliflags.BaseURIFlag),
89+
"api/v2/projects",
90+
projectKey,
91+
)
92+
res, err := client.MakeRequest(
93+
viper.GetString(cliflags.AccessTokenFlag),
94+
"GET",
95+
u,
96+
"application/json",
97+
nil,
98+
nil,
99+
false,
100+
)
101+
if err != nil {
102+
return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag))
103+
}
104+
105+
var projectResult struct {
106+
ID string `json:"_id"`
107+
}
108+
if err = json.Unmarshal(res, &projectResult); err != nil {
109+
return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag))
110+
}
111+
if projectResult.ID == "" {
112+
return fmt.Errorf("project %s not found", projectKey)
113+
}
114+
115+
appVersion := viper.GetString(appVersionFlag)
116+
path := viper.GetString(pathFlag)
117+
basePath := viper.GetString(basePathFlag)
118+
backendUrl := viper.GetString(backendUrlFlag)
119+
120+
if backendUrl == "" {
121+
backendUrl = defaultBackendUrl
122+
}
123+
124+
fmt.Printf("Starting to upload source maps from %s\n", path)
125+
126+
files, err := getAllSourceMapFiles(path)
127+
if err != nil {
128+
return fmt.Errorf("failed to find sourcemap files: %w", err)
129+
}
130+
131+
if len(files) == 0 {
132+
return fmt.Errorf("no source maps found in %s, is this the correct path?", path)
133+
}
134+
135+
s3Keys := make([]string, 0, len(files))
136+
for _, file := range files {
137+
s3Keys = append(s3Keys, getS3Key(appVersion, basePath, file.Name))
138+
}
139+
140+
uploadUrls, err := getSourceMapUploadUrls(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, s3Keys, backendUrl)
141+
if err != nil {
142+
return fmt.Errorf("failed to get upload URLs: %w", err)
143+
}
144+
145+
for i, file := range files {
146+
if err := uploadFile(file.Path, uploadUrls[i], file.Name); err != nil {
147+
return fmt.Errorf("failed to upload file %s: %w", file.Path, err)
148+
}
149+
}
150+
151+
fmt.Println("Successfully uploaded all sourcemaps")
152+
return nil
153+
}
154+
}
155+
156+
func getAllSourceMapFiles(path string) ([]SourceMapFile, error) {
157+
var files []SourceMapFile
158+
routeGroupPattern := regexp.MustCompile(`\(.+?\)/`)
159+
160+
fileInfo, err := os.Stat(path)
161+
if err != nil {
162+
return nil, err
163+
}
164+
165+
if !fileInfo.IsDir() {
166+
files = append(files, SourceMapFile{
167+
Path: path,
168+
Name: filepath.Base(path),
169+
})
170+
return files, nil
171+
}
172+
173+
err = filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error {
174+
if err != nil {
175+
return err
176+
}
177+
178+
if d.IsDir() && d.Name() == "node_modules" {
179+
return filepath.SkipDir
180+
}
181+
182+
if !d.IsDir() && (strings.HasSuffix(filePath, ".js.map") || strings.HasSuffix(filePath, ".js")) {
183+
relPath, err := filepath.Rel(path, filePath)
184+
if err != nil {
185+
return err
186+
}
187+
188+
files = append(files, SourceMapFile{
189+
Path: filePath,
190+
Name: relPath,
191+
})
192+
193+
routeGroupRemovedPath := routeGroupPattern.ReplaceAllString(relPath, "")
194+
if routeGroupRemovedPath != relPath {
195+
files = append(files, SourceMapFile{
196+
Path: filePath,
197+
Name: routeGroupRemovedPath,
198+
})
199+
}
200+
}
201+
202+
return nil
203+
})
204+
205+
if err != nil {
206+
return nil, err
207+
}
208+
209+
if len(files) == 0 {
210+
return nil, fmt.Errorf("no .js.map files found. Please double check that you have generated sourcemaps for your app")
211+
}
212+
213+
return files, nil
214+
}
215+
216+
func getS3Key(version, basePath, fileName string) string {
217+
if version == "" {
218+
version = "unversioned"
219+
}
220+
221+
if basePath != "" && !strings.HasSuffix(basePath, "/") {
222+
basePath = basePath + "/"
223+
}
224+
225+
return fmt.Sprintf("%s/%s%s", version, basePath, fileName)
226+
}
227+
228+
func getSourceMapUploadUrls(apiKey, projectID string, paths []string, backendUrl string) ([]string, error) {
229+
variables := map[string]interface{}{
230+
"api_key": apiKey,
231+
"project_id": projectID,
232+
"paths": paths,
233+
}
234+
235+
reqBody, err := json.Marshal(map[string]interface{}{
236+
"query": getSourceMapUrlsQuery,
237+
"variables": variables,
238+
})
239+
if err != nil {
240+
return nil, err
241+
}
242+
243+
req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody))
244+
if err != nil {
245+
return nil, err
246+
}
247+
248+
req.Header.Set("Content-Type", "application/json")
249+
250+
client := &http.Client{}
251+
resp, err := client.Do(req)
252+
if err != nil {
253+
return nil, err
254+
}
255+
defer resp.Body.Close()
256+
257+
body, err := io.ReadAll(resp.Body)
258+
if err != nil {
259+
return nil, err
260+
}
261+
262+
var urlsResp SourceMapUrlsResponse
263+
if err := json.Unmarshal(body, &urlsResp); err != nil {
264+
return nil, err
265+
}
266+
267+
if len(urlsResp.Data.GetSourceMapUploadUrls) == 0 {
268+
return nil, fmt.Errorf("unable to generate source map upload urls %w", err)
269+
}
270+
271+
return urlsResp.Data.GetSourceMapUploadUrls, nil
272+
}
273+
274+
func uploadFile(filePath, uploadUrl, name string) error {
275+
fileContent, err := os.ReadFile(filePath)
276+
if err != nil {
277+
return err
278+
}
279+
280+
req, err := http.NewRequest("PUT", uploadUrl, bytes.NewBuffer(fileContent))
281+
if err != nil {
282+
return err
283+
}
284+
285+
client := &http.Client{}
286+
resp, err := client.Do(req)
287+
if err != nil {
288+
return err
289+
}
290+
defer resp.Body.Close()
291+
292+
if resp.StatusCode != http.StatusOK {
293+
return fmt.Errorf("upload failed with status code: %d", resp.StatusCode)
294+
}
295+
296+
fmt.Printf("[LaunchDarkly] Uploaded %s to %s\n", filePath, name)
297+
return nil
298+
}
299+
300+
func initFlags(cmd *cobra.Command) {
301+
cmd.Flags().String(cliflags.ProjectFlag, "", "The project key")
302+
_ = cmd.MarkFlagRequired(cliflags.ProjectFlag)
303+
_ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"})
304+
_ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag))
305+
306+
cmd.Flags().String(appVersionFlag, "", "The current version of your deploy")
307+
_ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag))
308+
309+
cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the sourcemaps are")
310+
_ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag))
311+
312+
cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded sourcemaps")
313+
_ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag))
314+
315+
cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments")
316+
_ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag))
317+
}

0 commit comments

Comments
 (0)