Skip to content
225 changes: 225 additions & 0 deletions cmd/symbols/flutter_upload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package symbols

import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/launchdarkly/ldcli/internal/symbols/flutter"
)

const (
// flutterSymbolsIDPrefix is the Id-lane storage segment for Flutter/Dart
// symbol maps. Each map is keyed by the Dart snapshot build id (symbols_id):
// _sym/flutter/id/<symbolsID>/app.dartmap. The backend derives the same key
// from the symbols_id an obfuscated crash reports.
flutterSymbolsIDPrefix = "_sym/flutter/id"

// flutterSymbolExt is the object extension for a compiled Flutter symbol map
// (the shared dsymmap binary format under a Flutter-specific name).
flutterSymbolExt = ".dartmap"

// flutterSymbolMapName is the Id-lane object name. A symbols_id is unique per
// (build, arch), so it fully identifies one map and no platform token is
// needed in the Id-lane key.
flutterSymbolMapName = "app" + flutterSymbolExt

// flutterSymbolFileSuffix is the Flutter debug-symbols file `ldcli` discovers
// for --type flutter (e.g. app.android-arm64.symbols).
flutterSymbolFileSuffix = ".symbols"
)

// flutterUpload is one .dartmap object to store at one key. A map is uploaded to
// the Id lane always, and to the Version lane too when --app-version is given
// (same bytes, two keys).
type flutterUpload struct {
Data []byte
Key string
Label string
}

// uploadFlutterSymbols discovers app.*.symbols files under path, compiles each
// to a .dartmap, and uploads it to the Id lane (and the Version lane when
// appVersion is set).
func uploadFlutterSymbols(apiKey, projectID, path, appVersion, backendURL string) error {
uploads, err := buildFlutterMaps(path, appVersion)
if err != nil {
return err
}

keys := make([]string, len(uploads))
for i, u := range uploads {
keys[i] = u.Key
}

uploadURLs, err := getSymbolUploadUrls(apiKey, projectID, keys, backendURL)
if err != nil {
return fmt.Errorf("failed to get upload URLs: %w", err)
}
// One URL per requested key, in order; a short list would misalign the pairing.
if len(uploadURLs) != len(uploads) {
return fmt.Errorf("expected %d upload URLs but received %d", len(uploads), len(uploadURLs))
}

for i, u := range uploads {
if err := uploadBytes(u.Data, uploadURLs[i], u.Label); err != nil {
return fmt.Errorf("failed to upload symbol map %s: %w", u.Label, err)
}
}

fmt.Println("Successfully uploaded all symbols")
return nil
}

// buildFlutterMaps compiles every discovered app.*.symbols to a .dartmap and
// returns the objects to store, deduplicating by symbols_id (the same build can
// be discovered more than once). Each map yields an Id-lane upload, plus a
// Version-lane upload when appVersion and a platform token are both available.
func buildFlutterMaps(path, appVersion string) ([]flutterUpload, error) {
files, err := findFlutterSymbolFiles(path)
if err != nil {
return nil, fmt.Errorf("failed to find Flutter symbol files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no Flutter symbol files found in %s (looked for app.*.symbols). Build with `flutter build ... --obfuscate --split-debug-info=<dir>`", path)
}

var uploads []flutterUpload
seenID := make(map[string]bool)
seenVersionKey := make(map[string]bool)
var noBuildID []string
var missingAppVersion, missingPlatform int
for _, file := range files {
img, err := flutter.BuildFromELF(file)
if err != nil {
return nil, fmt.Errorf("failed to process %s: %w", file, err)
}

var buf bytes.Buffer
if err := img.Builder.Encode(&buf); err != nil {
return nil, fmt.Errorf("failed to encode symbol map for %s: %w", file, err)
}
data := buf.Bytes()

// iOS/macOS .symbols files carry no Dart build id (see readBuildID), so the
// Id lane can't be keyed from the file. Fall back to the Version lane, which
// the backend also tries for Flutter crashes (keyed by app version +
// platform). This requires --app-version and a platform token.
if img.SymbolsID == "" {
if appVersion == "" || img.Platform == "" {
noBuildID = append(noBuildID, file)
if appVersion == "" {
missingAppVersion++
fmt.Printf("Skipping %s: no build id in file (e.g. iOS .symbols). Re-run with --app-version to upload it to the Version lane.\n", filepath.Base(file))
} else {
missingPlatform++
fmt.Printf("Skipping %s: no build id in file and no platform token could be parsed from the filename (expected app.<platform>.symbols), so the Version lane can't be keyed.\n", filepath.Base(file))
}
continue
}
vKey := flutterVersionKey(appVersion, img.Platform)
if seenVersionKey[vKey] {
continue
}
seenVersionKey[vKey] = true
uploads = append(uploads, flutterUpload{
Data: data,
Key: vKey,
Label: fmt.Sprintf("%s (Version Lane, no build id)", img.Platform),
})
fmt.Printf("Built symbol map for %s (Version lane only, no build id, %d bytes)\n", img.Platform, len(data))
continue
}

if seenID[img.SymbolsID] {
continue
}
seenID[img.SymbolsID] = true

uploads = append(uploads, flutterUpload{
Data: data,
Key: flutterIDKey(img.SymbolsID),
Label: fmt.Sprintf("%s (%s, Id Lane)", img.SymbolsID, img.Platform),
})
if appVersion != "" && img.Platform != "" {
vKey := flutterVersionKey(appVersion, img.Platform)
if !seenVersionKey[vKey] {
seenVersionKey[vKey] = true
uploads = append(uploads, flutterUpload{
Data: data,
Key: vKey,
Label: fmt.Sprintf("%s (%s, Version Lane)", img.SymbolsID, img.Platform),
})
}
}
fmt.Printf("Built symbol map for %s (%s, %d bytes)\n", img.SymbolsID, img.Platform, len(data))
}

if len(uploads) == 0 {
if len(noBuildID) > 0 {
switch {
case missingAppVersion > 0 && missingPlatform > 0:
return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) that could not be uploaded: %d were missing --app-version, and %d had no platform token parsable from the filename (expected app.<platform>.symbols). Re-run with --app-version <app-version> and ensure filenames include a platform token to use the Version lane", len(noBuildID), missingAppVersion, missingPlatform)
case missingPlatform > 0:
return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) and no platform token parsable from the filename (expected app.<platform>.symbols), so the Version lane could not be keyed and none could be uploaded", len(noBuildID))
default:
return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) and no --app-version was given, so none could be uploaded. Re-run with --app-version <app-version> to use the Version lane", len(noBuildID))
}
}
return nil, fmt.Errorf("no Flutter symbol maps could be built from %s", path)
}
return uploads, nil
}

// flutterIDKey is the Id-lane storage key for a symbols_id:
// _sym/flutter/id/<symbolsID>/app.dartmap.
func flutterIDKey(symbolsID string) string {
return fmt.Sprintf("%s/%s/%s", flutterSymbolsIDPrefix, symbolsID, flutterSymbolMapName)
}

// flutterVersionKey is the Version-lane storage key for a platform token:
// <version>/app.<platform>.dartmap. The platform token (e.g. "android-arm64")
// disambiguates the per-arch maps that share one app version, and matches the
// "<os>-<arch>" the backend builds from the crash header.
func flutterVersionKey(version, platform string) string {
return fmt.Sprintf("%s/app.%s%s", version, platform, flutterSymbolExt)
}

// findFlutterSymbolFiles resolves path to the app.*.symbols files to compile.
// path may be a single .symbols file or a directory tree (e.g. the
// --split-debug-info output folder).
func findFlutterSymbolFiles(path string) ([]string, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.IsDir() {
if !isFlutterSymbolFile(path) {
return nil, fmt.Errorf("file %s is not a Flutter symbol file (expected app.*.symbols)", path)
}
return []string{path}, nil
}

var out []string
err = filepath.WalkDir(path, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !d.IsDir() && isFlutterSymbolFile(p) {
out = append(out, p)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}

func isFlutterSymbolFile(name string) bool {
base := filepath.Base(name)
return strings.HasPrefix(base, "app.") && strings.HasSuffix(base, flutterSymbolFileSuffix)
}
27 changes: 25 additions & 2 deletions cmd/symbols/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func generateRunE() func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
symbolType := canonicalizeSymbolType(viper.GetString(typeFlag))
if !isSupportedType(symbolType) {
return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM)
return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter)
}

path := viper.GetString(pathFlag)
Expand All @@ -79,6 +79,12 @@ func generateRunE() func(cmd *cobra.Command, args []string) error {
return generateAppleDSYMs(path, outputDir)
}

// Flutter symbols compile to .dartmap maps keyed by build id (Id Lane),
// plus a Version-lane copy when --app-version is set.
if symbolType == typeFlutter {
return generateFlutterSymbols(path, viper.GetString(appVersionFlag), outputDir)
}

return generateSymbolFiles(symbolType, path, outputDir)
}
}
Expand Down Expand Up @@ -113,6 +119,23 @@ func generateAppleDSYMs(path, outputDir string) error {
return nil
}

// generateFlutterSymbols compiles the discovered app.*.symbols to .dartmap
// symbol maps and writes them under outputDir using the same storage keys
// `symbols upload` would use (Id lane, plus Version lane when appVersion is set).
func generateFlutterSymbols(path, appVersion, outputDir string) error {
uploads, err := buildFlutterMaps(path, appVersion)
if err != nil {
return err
}
for _, u := range uploads {
if err := writeSymbolFile(outputDir, u.Key, u.Data); err != nil {
return fmt.Errorf("failed to write symbol map %s: %w", u.Label, err)
}
}
fmt.Printf("Successfully generated %d symbol file(s) in %s\n", len(uploads), outputDir)
return nil
}

// generateSymbolFiles discovers React Native or Android artifacts and copies
// each one to outputDir under the same storage key `symbols upload` would use,
// so the generated folder matches what the backend expects.
Expand Down Expand Up @@ -166,7 +189,7 @@ func writeSymbolFile(outputDir, key string, data []byte) error {
}

func initGenerateFlags(cmd *cobra.Command) {
cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to generate (supported: %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym)", typeReactNative, typeAndroid, typeAppleDSYM, typeAppleDSYM))
cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to generate (supported: %s, %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym; %s also accepts dart)", typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter, typeAppleDSYM, typeFlutter))
_ = cmd.MarkFlagRequired(typeFlag)
_ = cmd.Flags().SetAnnotation(typeFlag, "required", []string{"true"})
_ = viper.BindPFlag(typeFlag, cmd.Flags().Lookup(typeFlag))
Expand Down
21 changes: 18 additions & 3 deletions cmd/symbols/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ const (
// the canonical value; see symbolTypeAliases for accepted synonyms.
typeAppleDSYM = "apple-dsym"

// typeFlutter compiles Flutter/Dart AOT debug symbols (app.<platform>.symbols)
// into per-build .dartmap symbol maps (keyed by the Dart snapshot build id,
// surfaced as symbols_id) for obfuscated Dart crash symbolication.
typeFlutter = "flutter"

// getSymbolUrlsQuery uses the dedicated `get_symbol_upload_urls_ld` query
// (separate from `sourcemaps upload`) so symbol uploads travel over the
// symbol endpoint, which accepts larger, multi-segment uploads.
Expand Down Expand Up @@ -136,7 +141,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error
return func(cmd *cobra.Command, args []string) error {
symbolType := canonicalizeSymbolType(viper.GetString(typeFlag))
if !isSupportedType(symbolType) {
return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM)
return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter)
}

projectKey := viper.GetString(cliflags.ProjectFlag)
Expand Down Expand Up @@ -185,6 +190,14 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error
return uploadAppleDSYMs(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, path, backendUrl)
}

// Flutter/Dart symbols take a dedicated path too: each app.<platform>.symbols
// is compiled to a .dartmap keyed by its build id (Id Lane), plus a
// Version-lane copy when --app-version is set.
if symbolType == typeFlutter {
fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path)
return uploadFlutterSymbols(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, path, appVersion, backendUrl)
}

symbolsIDPrefix := symbolsIDPrefixForType(symbolType)

fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path)
Expand Down Expand Up @@ -255,6 +268,8 @@ var symbolTypeAliases = map[string]string{
"visionos": typeAppleDSYM,
"macos": typeAppleDSYM,
"osx": typeAppleDSYM,
"flutter": typeFlutter,
"dart": typeFlutter,
}

// canonicalizeSymbolType resolves a user-supplied --type to its canonical value.
Expand All @@ -270,7 +285,7 @@ func canonicalizeSymbolType(symbolType string) string {
}

func isSupportedType(symbolType string) bool {
return symbolType == typeReactNative || symbolType == typeAndroid || symbolType == typeAppleDSYM
return symbolType == typeReactNative || symbolType == typeAndroid || symbolType == typeAppleDSYM || symbolType == typeFlutter
}

// symbolsIDPrefixForType picks the Symbols Id Lane storage segment for the symbol
Expand Down Expand Up @@ -501,7 +516,7 @@ func uploadFile(filePath, uploadUrl, name string) error {
}

func initFlags(cmd *cobra.Command) {
cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to upload (supported: %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym)", typeReactNative, typeAndroid, typeAppleDSYM, typeAppleDSYM))
cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to upload (supported: %s, %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym; %s also accepts dart)", typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter, typeAppleDSYM, typeFlutter))
_ = cmd.MarkFlagRequired(typeFlag)
_ = cmd.Flags().SetAnnotation(typeFlag, "required", []string{"true"})
_ = viper.BindPFlag(typeFlag, cmd.Flags().Lookup(typeFlag))
Expand Down
Loading
Loading