Skip to content
Merged
Show file tree
Hide file tree
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] May 14, 2025
953f9a5
Update sourcemaps command to use @highlight-run/sourcemap-uploader np…
devin-ai-integration[bot] May 15, 2025
c4c2673
Fix variable shadowing issues in sourcemaps upload command
devin-ai-integration[bot] May 15, 2025
85058bf
Revert npm package integration and implement sourcemap uploader direc…
devin-ai-integration[bot] May 15, 2025
7040d52
local testing
Vadman97 May 21, 2025
5a51097
Add unit tests for sourcemap upload command
devin-ai-integration[bot] May 21, 2025
682ca2d
Fix CI failures in sourcemap upload tests
devin-ai-integration[bot] May 21, 2025
65971fb
unused import
Vadman97 May 21, 2025
79e9078
Improve test coverage for sourcemap upload command
devin-ai-integration[bot] May 21, 2025
cb7b813
fix test
Vadman97 May 21, 2025
ea4fd1e
Fix CI linting errors in sourcemaps command
devin-ai-integration[bot] May 21, 2025
1619a27
Fix remaining staticcheck errors in upload_test.go
devin-ai-integration[bot] May 21, 2025
b042b4d
Remove environment variable fallback for API key
devin-ai-integration[bot] May 21, 2025
e1a40b6
update command
Vadman97 May 23, 2025
37f077d
update gql
Vadman97 May 23, 2025
9faebc9
fixup! update gql
Vadman97 May 23, 2025
45a4e0d
Update upload_test.go to match changes in upload.go
devin-ai-integration[bot] May 23, 2025
17d49fd
Fix mockResourcesClient implementation to match Client interface
devin-ai-integration[bot] May 23, 2025
4584d28
working
Vadman97 May 23, 2025
8209153
docs: Add detailed documentation to verifyApiKey function
devin-ai-integration[bot] May 24, 2025
a12909a
fix: Update project response structure to use direct ID field
devin-ai-integration[bot] May 24, 2025
0797615
fix: Use %w instead of %v for error wrapping
devin-ai-integration[bot] May 24, 2025
4faca5e
lookup single project correctly
Vadman97 May 27, 2025
1440b14
docs: Update verifyApiKey documentation to clarify Highlight project ID
devin-ai-integration[bot] May 27, 2025
4832765
refactor per feedback to authenticate access token in highlight
Vadman97 May 28, 2025
a891dec
fix schema and path
Vadman97 May 29, 2025
5feb695
fix: Update upload_test.go to match latest changes in upload.go
devin-ai-integration[bot] May 29, 2025
8580b6f
error message
Vadman97 May 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
logincmd "github.com/launchdarkly/ldcli/cmd/login"
memberscmd "github.com/launchdarkly/ldcli/cmd/members"
resourcecmd "github.com/launchdarkly/ldcli/cmd/resources"
sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps"
"github.com/launchdarkly/ldcli/internal/analytics"
"github.com/launchdarkly/ldcli/internal/config"
"github.com/launchdarkly/ldcli/internal/dev_server"
Expand Down Expand Up @@ -203,6 +204,7 @@ func NewRootCommand(
cmd.AddCommand(logincmd.NewLoginCmd(resources.NewClient(version)))
cmd.AddCommand(resourcecmd.NewResourcesCmd())
cmd.AddCommand(devcmd.NewDevServerCmd(resources.NewClient(version), analyticsTrackerFn, dev_server.NewClient(version)))
cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd())
resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn)

// add non-generated commands
Expand Down
22 changes: 22 additions & 0 deletions cmd/sourcemaps/sourcemaps.go
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
}
317 changes: 317 additions & 0 deletions cmd/sourcemaps/upload.go
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")
Comment thread
mmrj marked this conversation as resolved.
_ = 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))
}
Loading
Loading