-
Notifications
You must be signed in to change notification settings - Fork 13
feat: Add command for uploading frontend sourcemaps [OB-143] #531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
81c7a16
Add sourcemaps command for uploading frontend sourcemaps
devin-ai-integration[bot] 953f9a5
Update sourcemaps command to use @highlight-run/sourcemap-uploader np…
devin-ai-integration[bot] c4c2673
Fix variable shadowing issues in sourcemaps upload command
devin-ai-integration[bot] 85058bf
Revert npm package integration and implement sourcemap uploader direc…
devin-ai-integration[bot] 7040d52
local testing
Vadman97 5a51097
Add unit tests for sourcemap upload command
devin-ai-integration[bot] 682ca2d
Fix CI failures in sourcemap upload tests
devin-ai-integration[bot] 65971fb
unused import
Vadman97 79e9078
Improve test coverage for sourcemap upload command
devin-ai-integration[bot] cb7b813
fix test
Vadman97 ea4fd1e
Fix CI linting errors in sourcemaps command
devin-ai-integration[bot] 1619a27
Fix remaining staticcheck errors in upload_test.go
devin-ai-integration[bot] b042b4d
Remove environment variable fallback for API key
devin-ai-integration[bot] e1a40b6
update command
Vadman97 37f077d
update gql
Vadman97 9faebc9
fixup! update gql
Vadman97 45a4e0d
Update upload_test.go to match changes in upload.go
devin-ai-integration[bot] 17d49fd
Fix mockResourcesClient implementation to match Client interface
devin-ai-integration[bot] 4584d28
working
Vadman97 8209153
docs: Add detailed documentation to verifyApiKey function
devin-ai-integration[bot] a12909a
fix: Update project response structure to use direct ID field
devin-ai-integration[bot] 0797615
fix: Use %w instead of %v for error wrapping
devin-ai-integration[bot] 4faca5e
lookup single project correctly
Vadman97 1440b14
docs: Update verifyApiKey documentation to clarify Highlight project ID
devin-ai-integration[bot] 4832765
refactor per feedback to authenticate access token in highlight
Vadman97 a891dec
fix schema and path
Vadman97 5feb695
fix: Update upload_test.go to match latest changes in upload.go
devin-ai-integration[bot] 8580b6f
error message
Vadman97 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package sourcemaps | ||
|
|
||
| import ( | ||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/launchdarkly/ldcli/internal/resources" | ||
| ) | ||
|
|
||
| func NewSourcemapsCmd() *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "sourcemaps", | ||
| Short: "Manage sourcemaps", | ||
| Long: "Manage sourcemaps for LaunchDarkly error monitoring", | ||
| Run: func(cmd *cobra.Command, args []string) { | ||
| _ = cmd.Help() | ||
| }, | ||
| } | ||
|
|
||
| cmd.AddCommand(NewUploadCmd(resources.NewClient(""))) | ||
|
|
||
| return cmd | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,317 @@ | ||
| package sourcemaps | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "io/fs" | ||
| "net/http" | ||
| "net/url" | ||
| "os" | ||
| "path/filepath" | ||
| "regexp" | ||
| "strings" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| "github.com/spf13/viper" | ||
|
|
||
| "github.com/launchdarkly/ldcli/cmd/cliflags" | ||
| resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" | ||
| "github.com/launchdarkly/ldcli/cmd/validators" | ||
| "github.com/launchdarkly/ldcli/internal/output" | ||
| "github.com/launchdarkly/ldcli/internal/resources" | ||
| ) | ||
|
|
||
| const ( | ||
| appVersionFlag = "app-version" | ||
| pathFlag = "path" | ||
| basePathFlag = "base-path" | ||
| backendUrlFlag = "backend-url" | ||
|
|
||
| defaultPath = "." | ||
| defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" | ||
|
|
||
| getSourceMapUrlsQuery = ` | ||
| query GetSourceMapUploadUrls($api_key: String!, $project_id: String!, $paths: [String!]!) { | ||
| get_source_map_upload_urls_ld( | ||
| api_key: $api_key | ||
| project_id: $project_id | ||
| paths: $paths | ||
| ) | ||
| } | ||
| ` | ||
| ) | ||
|
|
||
| type ApiKeyResponse struct { | ||
| Data struct { | ||
| Credential struct { | ||
| ProjectID string `json:"project_id"` | ||
| APIKey string `json:"api_key"` | ||
| } `json:"ld_credential"` | ||
| } `json:"data"` | ||
| Errors []struct { | ||
| Message string `json:"message"` | ||
| } `json:"errors"` | ||
| } | ||
|
|
||
| type SourceMapUrlsResponse struct { | ||
| Data struct { | ||
| GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls_ld"` | ||
| } `json:"data"` | ||
| } | ||
|
|
||
| type SourceMapFile struct { | ||
| Path string | ||
| Name string | ||
| } | ||
|
|
||
| func NewUploadCmd(client resources.Client) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Args: validators.Validate(), | ||
| Use: "upload", | ||
| Short: "Upload sourcemaps", | ||
| Long: "Upload JavaScript sourcemaps to LaunchDarkly for error monitoring", | ||
| RunE: runE(client), | ||
| } | ||
|
|
||
| cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) | ||
| initFlags(cmd) | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { | ||
| return func(cmd *cobra.Command, args []string) error { | ||
| projectKey := viper.GetString(cliflags.ProjectFlag) | ||
| u, _ := url.JoinPath( | ||
| viper.GetString(cliflags.BaseURIFlag), | ||
| "api/v2/projects", | ||
| projectKey, | ||
| ) | ||
| res, err := client.MakeRequest( | ||
| viper.GetString(cliflags.AccessTokenFlag), | ||
| "GET", | ||
| u, | ||
| "application/json", | ||
| nil, | ||
| nil, | ||
| false, | ||
| ) | ||
| if err != nil { | ||
| return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) | ||
| } | ||
|
|
||
| var projectResult struct { | ||
| ID string `json:"_id"` | ||
| } | ||
| if err = json.Unmarshal(res, &projectResult); err != nil { | ||
| return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) | ||
| } | ||
| if projectResult.ID == "" { | ||
| return fmt.Errorf("project %s not found", projectKey) | ||
| } | ||
|
|
||
| appVersion := viper.GetString(appVersionFlag) | ||
| path := viper.GetString(pathFlag) | ||
| basePath := viper.GetString(basePathFlag) | ||
| backendUrl := viper.GetString(backendUrlFlag) | ||
|
|
||
| if backendUrl == "" { | ||
| backendUrl = defaultBackendUrl | ||
| } | ||
|
|
||
| fmt.Printf("Starting to upload source maps from %s\n", path) | ||
|
|
||
| files, err := getAllSourceMapFiles(path) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to find sourcemap files: %w", err) | ||
| } | ||
|
|
||
| if len(files) == 0 { | ||
| return fmt.Errorf("no source maps found in %s, is this the correct path?", path) | ||
| } | ||
|
|
||
| s3Keys := make([]string, 0, len(files)) | ||
| for _, file := range files { | ||
| s3Keys = append(s3Keys, getS3Key(appVersion, basePath, file.Name)) | ||
| } | ||
|
|
||
| uploadUrls, err := getSourceMapUploadUrls(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, s3Keys, backendUrl) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to get upload URLs: %w", err) | ||
| } | ||
|
|
||
| for i, file := range files { | ||
| if err := uploadFile(file.Path, uploadUrls[i], file.Name); err != nil { | ||
| return fmt.Errorf("failed to upload file %s: %w", file.Path, err) | ||
| } | ||
| } | ||
|
|
||
| fmt.Println("Successfully uploaded all sourcemaps") | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { | ||
| var files []SourceMapFile | ||
| routeGroupPattern := regexp.MustCompile(`\(.+?\)/`) | ||
|
|
||
| fileInfo, err := os.Stat(path) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if !fileInfo.IsDir() { | ||
| files = append(files, SourceMapFile{ | ||
| Path: path, | ||
| Name: filepath.Base(path), | ||
| }) | ||
| return files, nil | ||
| } | ||
|
|
||
| err = filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| if d.IsDir() && d.Name() == "node_modules" { | ||
| return filepath.SkipDir | ||
| } | ||
|
|
||
| if !d.IsDir() && (strings.HasSuffix(filePath, ".js.map") || strings.HasSuffix(filePath, ".js")) { | ||
| relPath, err := filepath.Rel(path, filePath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| files = append(files, SourceMapFile{ | ||
| Path: filePath, | ||
| Name: relPath, | ||
| }) | ||
|
|
||
| routeGroupRemovedPath := routeGroupPattern.ReplaceAllString(relPath, "") | ||
| if routeGroupRemovedPath != relPath { | ||
| files = append(files, SourceMapFile{ | ||
| Path: filePath, | ||
| Name: routeGroupRemovedPath, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| }) | ||
|
|
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if len(files) == 0 { | ||
| return nil, fmt.Errorf("no .js.map files found. Please double check that you have generated sourcemaps for your app") | ||
| } | ||
|
|
||
| return files, nil | ||
| } | ||
|
|
||
| func getS3Key(version, basePath, fileName string) string { | ||
| if version == "" { | ||
| version = "unversioned" | ||
| } | ||
|
|
||
| if basePath != "" && !strings.HasSuffix(basePath, "/") { | ||
| basePath = basePath + "/" | ||
| } | ||
|
|
||
| return fmt.Sprintf("%s/%s%s", version, basePath, fileName) | ||
| } | ||
|
|
||
| func getSourceMapUploadUrls(apiKey, projectID string, paths []string, backendUrl string) ([]string, error) { | ||
| variables := map[string]interface{}{ | ||
| "api_key": apiKey, | ||
| "project_id": projectID, | ||
| "paths": paths, | ||
| } | ||
|
|
||
| reqBody, err := json.Marshal(map[string]interface{}{ | ||
| "query": getSourceMapUrlsQuery, | ||
| "variables": variables, | ||
| }) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| req.Header.Set("Content-Type", "application/json") | ||
|
|
||
| client := &http.Client{} | ||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| body, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var urlsResp SourceMapUrlsResponse | ||
| if err := json.Unmarshal(body, &urlsResp); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if len(urlsResp.Data.GetSourceMapUploadUrls) == 0 { | ||
| return nil, fmt.Errorf("unable to generate source map upload urls %w", err) | ||
| } | ||
|
|
||
| return urlsResp.Data.GetSourceMapUploadUrls, nil | ||
| } | ||
|
|
||
| func uploadFile(filePath, uploadUrl, name string) error { | ||
| fileContent, err := os.ReadFile(filePath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| req, err := http.NewRequest("PUT", uploadUrl, bytes.NewBuffer(fileContent)) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| client := &http.Client{} | ||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| return fmt.Errorf("upload failed with status code: %d", resp.StatusCode) | ||
| } | ||
|
|
||
| fmt.Printf("[LaunchDarkly] Uploaded %s to %s\n", filePath, name) | ||
| return nil | ||
| } | ||
|
|
||
| func initFlags(cmd *cobra.Command) { | ||
| cmd.Flags().String(cliflags.ProjectFlag, "", "The project key") | ||
| _ = cmd.MarkFlagRequired(cliflags.ProjectFlag) | ||
| _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) | ||
| _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) | ||
|
|
||
| cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") | ||
| _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) | ||
|
|
||
| cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the sourcemaps are") | ||
| _ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag)) | ||
|
|
||
| cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded sourcemaps") | ||
| _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) | ||
|
|
||
| cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments") | ||
| _ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag)) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.