From 085cdf585927ba8ed633e1b5172cb91288759284 Mon Sep 17 00:00:00 2001 From: Ben Ebsworth Date: Sat, 17 Oct 2026 19:07:23 +1100 Subject: [PATCH 1/4] Add opt-in crash reports (flaggr crash-reports on|off|status) When the user turns them on (flaggr crash-reports on, or FLAGGR_CRASH_REPORTS=1), a panic is reported to the Flaggr team at https://flaggr.dev/api/ingest/cli-crashes, then re-panics as before. The report carries the panic's message and stack, the CLI's version, the subcommand name (never its arguments or flags) and the OS, architecture and Go version: never the token, URLs, flag keys or output. The endpoint takes no credential. DO_NOT_TRACK=1 and CI keep reports off; off by default, a crash prints how to report it instead. Errors a command returns are not crashes and are never reported. Uses the Go SDK's errorreport package (pinned to its branch commit until flaggr-go tags a release); THIRD_PARTY_NOTICES regenerated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ README.md | 17 ++++ THIRD_PARTY_NOTICES | 28 ++++++ go.mod | 1 + go.sum | 2 + internal/cmd/crash_reports.go | 59 +++++++++++++ internal/cmd/crash_reports_test.go | 17 ++++ internal/cmd/root.go | 19 ++++- internal/config/config.go | 3 + internal/crash/crash.go | 124 +++++++++++++++++++++++++++ internal/crash/crash_test.go | 133 +++++++++++++++++++++++++++++ 11 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/crash_reports.go create mode 100644 internal/cmd/crash_reports_test.go create mode 100644 internal/crash/crash.go create mode 100644 internal/crash/crash_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a534e21..f0fc35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +FEATURES: + +* Opt-in crash reports: `flaggr crash-reports on|off|status` (or `FLAGGR_CRASH_REPORTS=1|0`). When they're on, a panic is reported to the Flaggr team with its message, stack, the CLI's version, the subcommand name and the OS, architecture and Go version. The report never includes the token, arguments or output. `DO_NOT_TRACK=1` and CI environments keep reports off. The panic still prints and exits as before. Reports use the Go SDK's new `errorreport` package. + ## 0.5.0 (October 10, 2026) First public release, from `github.com/flaggr-dev/flaggr-cli`. Earlier versions were built in Flaggr's private repository and were available only on request. diff --git a/README.md b/README.md index 022f6da..969cd0b 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ flaggr login --token fgp_your_personal_access_token # or a project API token ( | `flaggr audit log --project ` | The project's audit log, with diffs | | `flaggr audit history --key --service ` | A flag's version history | | `flaggr export --project ` | A project's configuration as JSON, to stdout or `--output ` | +| `flaggr crash-reports [on\|off\|status]` | Send the CLI's crashes to the Flaggr team (off by default) | Every command takes `--json` for JSON output. `flaggr --help` lists a command's flags. @@ -104,6 +105,22 @@ Every command takes `--json` for JSON output. `flaggr --help` lists a | `FLAGGR_API_URL` | `https://flaggr.dev` | The Flaggr API. For a self-hosted Flaggr, `flaggr login --url https://flaggr.example.com` saves it instead. | | `FLAGGR_API_TOKEN` | The saved token | The token every command sends | | `FLAGGR_EVAL_URL` | `https://api.flaggr.dev` with flaggr.dev, otherwise the API URL | Where `flaggr eval` sends evaluations | +| `FLAGGR_CRASH_REPORTS` | The saved setting (off) | `1` or `0` turns crash reports on or off | + +## Crash reports + +Crash reports are off unless you turn them on with `flaggr crash-reports on` or `FLAGGR_CRASH_REPORTS=1`. `DO_NOT_TRACK=1` and CI environments (`CI` set) always keep them off. + +When they're on and the CLI crashes (a panic), a report goes to the Flaggr team at `https://flaggr.dev/api/ingest/cli-crashes`. The report contains: + +- the panic's message and stack, +- the CLI's version, +- the subcommand that ran, such as `flags list`, never its arguments or flags, +- your OS, architecture and Go version. + +It never contains your token, URLs, flag keys or command output. The endpoint takes no credential and stores reports only in Flaggr's own project. It removes tokens and email addresses from the message and stack. + +Errors a command returns, such as a rejected token or a network failure, aren't crashes and are never reported. When reports are off, a crash prints how to report it instead. ## Development diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index f6352a5..8aea0a2 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -273,6 +273,34 @@ connectrpc.com/connect See the License for the specific language governing permissions and limitations under the License. +================================================================================ +github.com/flaggr-dev/flaggr-go +================================================================================ + +--- LICENSE --- + +MIT License + +Copyright (c) 2024-present Flaggr Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ github.com/inconshreveable/mousetrap ================================================================================ diff --git a/go.mod b/go.mod index af41ae3..a6144ee 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( ) require ( + github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/go.sum b/go.sum index 1371fae..81cb123 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e h1:vatAAsRLjxmi7mCb025Csofv9DlTEPlvJJ43yLHvDzg= +github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e/go.mod h1:aJQI0Ij2WDfeW7ok7UP9p6XbILlAPulAuwK5sAm7Boc= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/internal/cmd/crash_reports.go b/internal/cmd/crash_reports.go new file mode 100644 index 0000000..f2f776f --- /dev/null +++ b/internal/cmd/crash_reports.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/flaggr-dev/flaggr-cli/internal/config" + "github.com/flaggr-dev/flaggr-cli/internal/crash" + "github.com/flaggr-dev/flaggr-cli/internal/output" + "github.com/spf13/cobra" +) + +func newCrashReportsCmd() *cobra.Command { + return &cobra.Command{ + Use: "crash-reports [on|off|status]", + Short: "Send the CLI's crashes to the Flaggr team (off by default)", + ValidArgs: []string{"on", "off", "status"}, + Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), + Long: `Turn crash reports on or off, or show whether they are on. + +When the CLI crashes (a panic, not an error such as a bad token or a network +failure), a report goes to the Flaggr team: the panic's message and stack, +the CLI's version, the subcommand that ran (never its arguments or flags), +and your OS, architecture and Go version. Never your token, URLs, flag keys +or output. + +Reports are off unless you turn them on. FLAGGR_CRASH_REPORTS=1 or 0 +overrides this setting; DO_NOT_TRACK=1 and CI environments always turn +reports off.`, + RunE: func(cmd *cobra.Command, args []string) error { + action := "status" + if len(args) == 1 { + action = args[0] + } + c := config.Load() + if action == "on" || action == "off" { + on := action == "on" + c.CrashReports = &on + if err := config.Save(c); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + } + on, reason := crash.Enabled(c, os.Getenv) + state := "off" + if on { + state = "on" + } + if jsonOut { + output.JSON(map[string]any{"crashReports": on, "reason": reason}) + return nil + } + if action != "status" { + output.Success("Crash reports " + action) + } + fmt.Printf("Crash reports are %s (%s).\n", state, reason) + return nil + }, + } +} diff --git a/internal/cmd/crash_reports_test.go b/internal/cmd/crash_reports_test.go new file mode 100644 index 0000000..82adb3e --- /dev/null +++ b/internal/cmd/crash_reports_test.go @@ -0,0 +1,17 @@ +package cmd + +import "testing" + +func TestCommandPathNamesTheSubcommandOnly(t *testing.T) { + root := NewRootCmd() + cases := map[string][]string{ + "flags list": {"flags", "list", "--project", "secret-project", "--json"}, + "crash-reports": {"crash-reports", "on"}, + "": {"--version"}, + } + for want, args := range cases { + if got := commandPath(root, args); got != want { + t.Errorf("commandPath(%v) = %q, want %q", args, got, want) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 3a13914..90ab0f2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2,9 +2,11 @@ package cmd import ( "os" + "strings" "github.com/flaggr-dev/flaggr-cli/internal/api" "github.com/flaggr-dev/flaggr-cli/internal/config" + "github.com/flaggr-dev/flaggr-cli/internal/crash" "github.com/spf13/cobra" ) @@ -55,6 +57,7 @@ func NewRootCmd() *cobra.Command { newHealthCmd(), newAuditCmd(), newExportCmd(), + newCrashReportsCmd(), ) return root @@ -69,7 +72,21 @@ func commitKnown(commit string) bool { func Execute(version, commit string) { CLIVersion = version CLICommit = commit - if err := NewRootCmd().Execute(); err != nil { + root := NewRootCmd() + // A panic is reported to the Flaggr team when the user opted in, then + // re-panics (internal/crash). + defer crash.Guard{Version: version, Command: func() string { return commandPath(root, os.Args[1:]) }}.Recover() + if err := root.Execute(); err != nil { os.Exit(1) } } + +// commandPath is the subcommand args select, such as "flags list": the +// command names only, never their arguments or flags. +func commandPath(root *cobra.Command, args []string) string { + found, _, err := root.Find(args) + if err != nil || found == nil || found == root { + return "" + } + return strings.TrimPrefix(found.CommandPath(), root.Name()+" ") +} diff --git a/internal/config/config.go b/internal/config/config.go index 90c887c..b43d37e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,9 @@ type Config struct { APIToken string `json:"api_token"` DefaultProjectID string `json:"default_project_id,omitempty"` DefaultServiceID string `json:"default_service_id,omitempty"` + // CrashReports is whether the CLI sends its crashes to the Flaggr team + // (flaggr crash-reports on|off). Unset means off. + CrashReports *bool `json:"crash_reports,omitempty"` } func configDir() string { diff --git a/internal/crash/crash.go b/internal/crash/crash.go new file mode 100644 index 0000000..6dc9931 --- /dev/null +++ b/internal/crash/crash.go @@ -0,0 +1,124 @@ +// Package crash reports the CLI's own crashes (panics) to the Flaggr team, +// when the user has opted in. +// +// Reports go to https://flaggr.dev/api/ingest/cli-crashes, which takes no +// credential and stores them only in Flaggr's own project. A report carries +// the panic's message and stack, the CLI's version, the subcommand that ran +// (such as "flags list", never its arguments or flags), and the OS, +// architecture and Go version. Never the API token, URLs, flag keys or +// output. Flaggr scrubs tokens and addresses from the message and stack too. +// +// Reporting is off unless the user turns it on (`flaggr crash-reports on`, +// or FLAGGR_CRASH_REPORTS=1). DO_NOT_TRACK=1 and a CI environment always +// turn it off. Errors a command returns (a bad token, a network failure) +// are not crashes and are never reported. +package crash + +import ( + "fmt" + "io" + "os" + "runtime" + "strings" + "time" + + "github.com/flaggr-dev/flaggr-cli/internal/config" + "github.com/flaggr-dev/flaggr-go/errorreport" +) + +// DefaultIngestURL receives the CLI's crash reports. +const DefaultIngestURL = "https://flaggr.dev/api/ingest/cli-crashes" + +// Enabled reports whether crash reports are on, and why, from the +// environment and the config. getenv is os.Getenv outside tests. +func Enabled(cfg *config.Config, getenv func(string) string) (bool, string) { + if v := getenv("DO_NOT_TRACK"); v != "" && v != "0" && !strings.EqualFold(v, "false") { + return false, "DO_NOT_TRACK is set" + } + if v := getenv("CI"); v != "" && v != "0" && !strings.EqualFold(v, "false") { + return false, "running in CI" + } + switch strings.ToLower(strings.TrimSpace(getenv("FLAGGR_CRASH_REPORTS"))) { + case "1", "true", "on", "yes": + return true, "FLAGGR_CRASH_REPORTS is on" + case "0", "false", "off", "no": + return false, "FLAGGR_CRASH_REPORTS is off" + } + if cfg != nil && cfg.CrashReports != nil { + if *cfg.CrashReports { + return true, "turned on with `flaggr crash-reports on`" + } + return false, "turned off with `flaggr crash-reports off`" + } + return false, "off by default" +} + +// Guard runs at the top of the CLI: deferred, it recovers a panic, reports +// it when crash reports are on (or says how to turn them on when they're +// off), and re-panics so the CLI still fails with Go's panic output and exit +// code. command names the subcommand that ran. +type Guard struct { + Version string + Command func() string + Stderr io.Writer + Getenv func(string) string + // IngestURL overrides DefaultIngestURL (FLAGGR_CRASH_REPORTS_URL too). + IngestURL string +} + +// Recover must be deferred directly: defer guard.Recover(). +func (g Guard) Recover() { + value := recover() + if value == nil { + return + } + getenv := g.Getenv + if getenv == nil { + getenv = os.Getenv + } + stderr := g.Stderr + if stderr == nil { + stderr = os.Stderr + } + if on, _ := Enabled(config.Load(), getenv); on { + Report(value, g.Version, g.command(), g.ingestURL(getenv)) + fmt.Fprintln(stderr, "flaggr crashed. A crash report was sent to the Flaggr team; thank you.") + } else { + fmt.Fprintln(stderr, "flaggr crashed. Please report it at https://github.com/flaggr-dev/flaggr-cli/issues, or run `flaggr crash-reports on` to send crash reports automatically.") + } + panic(value) +} + +func (g Guard) command() string { + if g.Command == nil { + return "" + } + return g.Command() +} + +func (g Guard) ingestURL(getenv func(string) string) string { + if v := getenv("FLAGGR_CRASH_REPORTS_URL"); v != "" { + return v + } + if g.IngestURL != "" { + return g.IngestURL + } + return DefaultIngestURL +} + +// Report sends one panic and waits up to 3 s for it to go out. +func Report(value any, version, command, ingestURL string) { + reporter := errorreport.New(errorreport.Options{ + IngestURL: ingestURL, + Release: "flaggr-cli@" + strings.TrimPrefix(version, "v"), + Environment: "cli", + }) + reporter.CapturePanic(value, errorreport.WithExtra(map[string]any{ + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "goVersion": runtime.Version(), + "command": command, + })) + reporter.Flush(3 * time.Second) + reporter.Close() +} diff --git a/internal/crash/crash_test.go b/internal/crash/crash_test.go new file mode 100644 index 0000000..8f6ff81 --- /dev/null +++ b/internal/crash/crash_test.go @@ -0,0 +1,133 @@ +package crash + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/flaggr-dev/flaggr-cli/internal/config" +) + +func env(vars map[string]string) func(string) string { + return func(key string) string { return vars[key] } +} + +func TestEnabled(t *testing.T) { + on, off := true, false + cases := []struct { + name string + cfg *config.Config + vars map[string]string + want bool + }{ + {"off by default", &config.Config{}, nil, false}, + {"config on", &config.Config{CrashReports: &on}, nil, true}, + {"config off", &config.Config{CrashReports: &off}, nil, false}, + {"env on beats config off", &config.Config{CrashReports: &off}, map[string]string{"FLAGGR_CRASH_REPORTS": "1"}, true}, + {"env off beats config on", &config.Config{CrashReports: &on}, map[string]string{"FLAGGR_CRASH_REPORTS": "off"}, false}, + {"DO_NOT_TRACK beats everything", &config.Config{CrashReports: &on}, map[string]string{"DO_NOT_TRACK": "1", "FLAGGR_CRASH_REPORTS": "1"}, false}, + {"CI beats everything", &config.Config{CrashReports: &on}, map[string]string{"CI": "true"}, false}, + {"CI=false doesn't count", &config.Config{CrashReports: &on}, map[string]string{"CI": "false"}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, reason := Enabled(c.cfg, env(c.vars)) + if got != c.want || reason == "" { + t.Errorf("Enabled = %v (%q), want %v", got, reason, c.want) + } + }) + } +} + +type ingest struct { + mu sync.Mutex + auth []string + events []map[string]any +} + +func newIngest(t *testing.T) (*ingest, string) { + in := &ingest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Events []map[string]any `json:"events"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + in.mu.Lock() + in.auth = append(in.auth, r.Header.Get("Authorization")) + in.events = append(in.events, body.Events...) + in.mu.Unlock() + w.WriteHeader(http.StatusAccepted) + })) + t.Cleanup(srv.Close) + return in, srv.URL + "/api/ingest/cli-crashes" +} + +func crashWith(g Guard) (repanicked any) { + defer func() { repanicked = recover() }() + defer g.Recover() + var items []int + _ = items[3] // index out of range + return nil +} + +func TestGuardReportsWhenOnAndRepanics(t *testing.T) { + t.Setenv("HOME", t.TempDir()) // no config file: the env decides + in, url := newIngest(t) + var stderr bytes.Buffer + value := crashWith(Guard{ + Version: "v0.6.0", + Command: func() string { return "flags list" }, + Stderr: &stderr, + Getenv: env(map[string]string{"FLAGGR_CRASH_REPORTS": "1"}), + IngestURL: url, + }) + if value == nil { + t.Fatal("the panic was swallowed") + } + in.mu.Lock() + defer in.mu.Unlock() + if len(in.events) != 1 { + t.Fatalf("events = %d", len(in.events)) + } + e := in.events[0] + if in.auth[0] != "" { + t.Errorf("a crash report carries no credential, got %q", in.auth[0]) + } + data := e["data"].(map[string]any) + if e["release"] != "flaggr-cli@0.6.0" || e["environment"] != "cli" || data["command"] != "flags list" || data["os"] == nil || data["level"] != "fatal" { + t.Errorf("event = %v", e) + } + if !strings.Contains(e["message"].(string), "index out of range") { + t.Errorf("message = %v", e["message"]) + } + if !strings.Contains(stderr.String(), "crash report was sent") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestGuardOnlyHintsWhenOff(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + in, url := newIngest(t) + var stderr bytes.Buffer + if crashWith(Guard{Version: "0.6.0", Stderr: &stderr, Getenv: env(nil), IngestURL: url}) == nil { + t.Fatal("the panic was swallowed") + } + in.mu.Lock() + defer in.mu.Unlock() + if len(in.events) != 0 { + t.Errorf("sent %d events with reports off", len(in.events)) + } + if !strings.Contains(stderr.String(), "flaggr crash-reports on") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestGuardDoesNothingWithoutAPanic(t *testing.T) { + func() { + defer Guard{Getenv: env(map[string]string{"FLAGGR_CRASH_REPORTS": "1"}), IngestURL: "http://127.0.0.1:1"}.Recover() + }() +} From 460dd54a27b33492d1efa06518972f599933a68a Mon Sep 17 00:00:00 2001 From: Ben Ebsworth Date: Sat, 17 Oct 2026 21:08:58 +1100 Subject: [PATCH 2/4] Add flaggr sourcemaps upload|list|delete Uploads the .map files under a build directory (gzipped, four at a time) to PUT /api/projects/{id}/sourcemaps, each filed under its minified file's path in stack traces (--url-prefix plus its path in the directory, without .map), for --release. Flaggr's Issues page then shows minified frames as the original source. list shows releases or a release's files; delete removes a release's maps or one file's. --dry-run prints the mapping. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 12 ++ internal/api/sourcemaps.go | 127 +++++++++++++ internal/cmd/root.go | 1 + internal/cmd/sourcemaps.go | 306 ++++++++++++++++++++++++++++++++ internal/cmd/sourcemaps_test.go | 93 ++++++++++ 6 files changed, 540 insertions(+) create mode 100644 internal/api/sourcemaps.go create mode 100644 internal/cmd/sourcemaps.go create mode 100644 internal/cmd/sourcemaps_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fc35c..e73141f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ FEATURES: +* `flaggr sourcemaps upload|list|delete`: upload a release's source maps (`--release`, `--url-prefix`, `--dry-run`), gzipped and four at a time. Flaggr's Issues page then shows minified stack frames as your original source. * Opt-in crash reports: `flaggr crash-reports on|off|status` (or `FLAGGR_CRASH_REPORTS=1|0`). When they're on, a panic is reported to the Flaggr team with its message, stack, the CLI's version, the subcommand name and the OS, architecture and Go version. The report never includes the token, arguments or output. `DO_NOT_TRACK=1` and CI environments keep reports off. The panic still prints and exits as before. Reports use the Go SDK's new `errorreport` package. ## 0.5.0 (October 10, 2026) diff --git a/README.md b/README.md index 969cd0b..2eb014f 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ flaggr login --token fgp_your_personal_access_token # or a project API token ( | `flaggr audit log --project ` | The project's audit log, with diffs | | `flaggr audit history --key --service ` | A flag's version history | | `flaggr export --project ` | A project's configuration as JSON, to stdout or `--output ` | +| `flaggr sourcemaps upload --release --url-prefix ` | Upload source maps, so the Issues page shows minified stack traces as your source (also `sourcemaps list` and `sourcemaps delete`) | | `flaggr crash-reports [on\|off\|status]` | Send the CLI's crashes to the Flaggr team (off by default) | Every command takes `--json` for JSON output. `flaggr --help` lists a command's flags. @@ -107,6 +108,17 @@ Every command takes `--json` for JSON output. `flaggr --help` lists a | `FLAGGR_EVAL_URL` | `https://api.flaggr.dev` with flaggr.dev, otherwise the API URL | Where `flaggr eval` sends evaluations | | `FLAGGR_CRASH_REPORTS` | The saved setting (off) | `1` or `0` turns crash reports on or off | +## Source maps + +Upload the source maps of each release you deploy, under the release your app reports. Flaggr then shows the minified frames on the Issues page as the original file, line and source: + +```sh +# Next.js: the maps of .next/static, served under /_next/static +flaggr sourcemaps upload .next/static --url-prefix /_next/static --release "$GIT_SHA" --project +``` + +Each map is filed under the path its minified file has in stack traces: `--url-prefix` plus the map's path in the directory, without `.map`. Run `--dry-run` to see those paths without uploading anything. Maps are gzipped before they're sent, and each must be under 64 MiB. A project keeps the maps of its last 20 releases. Your build must emit source maps; for Next.js that means `productionBrowserSourceMaps: true`. You don't have to serve them publicly. + ## Crash reports Crash reports are off unless you turn them on with `flaggr crash-reports on` or `FLAGGR_CRASH_REPORTS=1`. `DO_NOT_TRACK=1` and CI environments (`CI` set) always keep them off. diff --git a/internal/api/sourcemaps.go b/internal/api/sourcemaps.go new file mode 100644 index 0000000..083278e --- /dev/null +++ b/internal/api/sourcemaps.go @@ -0,0 +1,127 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// SourceMapUpload is what the API answers for one uploaded map. +type SourceMapUpload struct { + Release string `json:"release"` + File string `json:"file"` + Bytes int `json:"bytes"` + Replaced bool `json:"replaced"` +} + +// SourceMapRelease is a release with uploaded maps. +type SourceMapRelease struct { + Release string `json:"release"` + Files int `json:"files"` + Bytes int64 `json:"bytes"` + LastUploadedAt string `json:"lastUploadedAt"` +} + +// SourceMapFile is one uploaded map of a release. +type SourceMapFile struct { + File string `json:"file"` + Bytes int64 `json:"bytes"` + UploadedBy string `json:"uploadedBy"` + UploadedAt string `json:"uploadedAt"` +} + +func sourceMapsPath(projectID string, query url.Values) string { + path := "/api/projects/" + url.PathEscape(projectID) + "/sourcemaps" + if len(query) > 0 { + path += "?" + query.Encode() + } + return path +} + +// UploadSourceMap sends one gzipped source map for the minified file `file` +// (its path in stack traces, such as /_next/static/chunks/app/page-3f9a.js). +func (c *RESTClient) UploadSourceMap(ctx context.Context, projectID, release, file string, gzipped []byte) (*SourceMapUpload, error) { + query := url.Values{"file": {file}} + if release != "" { + query.Set("release", release) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+sourceMapsPath(projectID, query), bytes.NewReader(gzipped)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "gzip") + if c.APIToken != "" { + req.Header.Set("Authorization", "Bearer "+c.APIToken) + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + var out SourceMapUpload + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &out, nil +} + +// ListSourceMapReleases lists a project's releases with maps, newest first. +func (c *RESTClient) ListSourceMapReleases(ctx context.Context, projectID string) ([]SourceMapRelease, error) { + data, err := c.do(ctx, http.MethodGet, sourceMapsPath(projectID, nil), nil) + if err != nil { + return nil, err + } + var out struct { + Releases []SourceMapRelease `json:"releases"` + } + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return out.Releases, nil +} + +// ListSourceMapFiles lists one release's maps. +func (c *RESTClient) ListSourceMapFiles(ctx context.Context, projectID, release string) ([]SourceMapFile, error) { + data, err := c.do(ctx, http.MethodGet, sourceMapsPath(projectID, url.Values{"release": {release}}), nil) + if err != nil { + return nil, err + } + var out struct { + Files []SourceMapFile `json:"files"` + } + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return out.Files, nil +} + +// DeleteSourceMaps deletes a release's maps, or one file's when file isn't empty. +func (c *RESTClient) DeleteSourceMaps(ctx context.Context, projectID, release, file string) (int, error) { + query := url.Values{"release": {release}} + if file != "" { + query.Set("file", file) + } + data, err := c.do(ctx, http.MethodDelete, sourceMapsPath(projectID, query), nil) + if err != nil { + return 0, err + } + var out struct { + Deleted int `json:"deleted"` + } + if err := json.Unmarshal(data, &out); err != nil { + return 0, fmt.Errorf("decode: %w", err) + } + return out.Deleted, nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 90ab0f2..fa92d50 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -58,6 +58,7 @@ func NewRootCmd() *cobra.Command { newAuditCmd(), newExportCmd(), newCrashReportsCmd(), + newSourceMapsCmd(), ) return root diff --git a/internal/cmd/sourcemaps.go b/internal/cmd/sourcemaps.go new file mode 100644 index 0000000..d9f012b --- /dev/null +++ b/internal/cmd/sourcemaps.go @@ -0,0 +1,306 @@ +package cmd + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/flaggr-dev/flaggr-cli/internal/api" + "github.com/flaggr-dev/flaggr-cli/internal/output" + "github.com/spf13/cobra" +) + +// maxSourceMapBytes is the largest map the API takes, decompressed. +const maxSourceMapBytes = 64 << 20 + +func newSourceMapsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sourcemaps", + Short: "Upload source maps, so issues show minified stack traces as your source", + Long: `Upload, list and delete a project's source maps. Flaggr uses them to show +the frames of minified stack traces (browser bundles, bundled servers) as +the original file, line and source on the Issues page. + +Upload the maps of each release you deploy, with the release your app reports +(the SDK's release option): + + flaggr sourcemaps upload .next/static --url-prefix /_next/static --release "$GIT_SHA" + +Each map is filed under the path of the minified file it belongs to, as stack +traces show it: --url-prefix plus the map's path in the directory, without +".map". A project keeps the maps of its last 20 releases.`, + } + cmd.AddCommand(newSourceMapsUploadCmd(), newSourceMapsListCmd(), newSourceMapsDeleteCmd()) + return cmd +} + +func projectOrDefault(projectID string) (string, error) { + if projectID != "" { + return projectID, nil + } + if cfg != nil && cfg.DefaultProjectID != "" { + return cfg.DefaultProjectID, nil + } + return "", errors.New("--project is required (no default project is saved)") +} + +// sourceMapTarget is one map to upload and the minified file it is for. +type sourceMapTarget struct { + Path string // on disk + File string // in stack traces +} + +// findSourceMaps lists the .map files under each argument (a directory, or +// a .map file), each with the path of its minified file: urlPrefix plus its +// path relative to the directory, without ".map". +func findSourceMaps(args []string, urlPrefix string) ([]sourceMapTarget, error) { + prefix := "/" + strings.Trim(urlPrefix, "/") + if prefix == "/" { + prefix = "" + } + var targets []sourceMapTarget + for _, arg := range args { + info, err := os.Stat(arg) + if err != nil { + return nil, err + } + if !info.IsDir() { + if !strings.HasSuffix(arg, ".map") { + return nil, fmt.Errorf("%s is not a .map file", arg) + } + targets = append(targets, sourceMapTarget{Path: arg, File: prefix + "/" + strings.TrimSuffix(filepath.Base(arg), ".map")}) + continue + } + err = filepath.WalkDir(arg, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && (d.Name() == "node_modules" || strings.HasPrefix(d.Name(), ".") && p != arg) { + return filepath.SkipDir + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".map") { + return nil + } + rel, err := filepath.Rel(arg, p) + if err != nil { + return err + } + targets = append(targets, sourceMapTarget{Path: p, File: prefix + "/" + strings.TrimSuffix(path.Clean(filepath.ToSlash(rel)), ".map")}) + return nil + }) + if err != nil { + return nil, err + } + } + sort.Slice(targets, func(i, j int) bool { return targets[i].File < targets[j].File }) + return targets, nil +} + +func gzipFile(p string) ([]byte, error) { + info, err := os.Stat(p) + if err != nil { + return nil, err + } + if info.Size() > maxSourceMapBytes { + return nil, fmt.Errorf("%s is over 64 MiB", p) + } + data, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var buf bytes.Buffer + zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if _, err := zw.Write(data); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func newSourceMapsUploadCmd() *cobra.Command { + var projectID, release, urlPrefix string + var dryRun bool + cmd := &cobra.Command{ + Use: "upload ...", + Short: "Upload the .map files under directories (or single .map files)", + Args: cobra.MinimumNArgs(1), + // A failed upload is reported per file above; usage text would bury it. + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + project, err := projectOrDefault(projectID) + if err != nil { + return err + } + targets, err := findSourceMaps(args, urlPrefix) + if err != nil { + return err + } + if len(targets) == 0 { + return errors.New("no .map files found") + } + if dryRun { + for _, t := range targets { + fmt.Printf("%s → %s\n", t.Path, t.File) + } + return nil + } + + type result struct { + target sourceMapTarget + upload *api.SourceMapUpload + err error + } + results := make([]result, len(targets)) + jobs := make(chan int) + var wg sync.WaitGroup + for w := 0; w < 4; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := range jobs { + t := targets[i] + body, err := gzipFile(t.Path) + if err == nil { + results[i].upload, err = rest.UploadSourceMap(cmd.Context(), project, release, t.File, body) + } + results[i].target, results[i].err = t, err + } + }() + } + for i := range targets { + jobs <- i + } + close(jobs) + wg.Wait() + + failed := 0 + for _, r := range results { + if r.err != nil { + failed++ + output.Error(fmt.Sprintf("%s: %v", r.target.File, r.err)) + } + } + if jsonOut { + out := make([]map[string]any, 0, len(results)) + for _, r := range results { + entry := map[string]any{"path": r.target.Path, "file": r.target.File} + if r.err != nil { + entry["error"] = r.err.Error() + } else { + entry["bytes"], entry["replaced"] = r.upload.Bytes, r.upload.Replaced + } + out = append(out, entry) + } + output.JSON(out) + } else { + label := release + if label == "" { + label = "(no release)" + } + output.Success(fmt.Sprintf("Uploaded %d of %d source maps for %s", len(targets)-failed, len(targets), label)) + } + if failed > 0 { + return fmt.Errorf("%d uploads failed", failed) + } + return nil + }, + } + cmd.Flags().StringVarP(&projectID, "project", "p", "", "Project ID (default: the saved default project)") + cmd.Flags().StringVarP(&release, "release", "r", "", "The release these maps belong to, as your app reports it") + cmd.Flags().StringVar(&urlPrefix, "url-prefix", "", "Path the directory is served under, such as /_next/static") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "List the maps and the files they'd be filed under, without uploading") + return cmd +} + +func newSourceMapsListCmd() *cobra.Command { + var projectID, release string + cmd := &cobra.Command{ + Use: "list", + Short: "List releases with source maps, or one release's files", + RunE: func(cmd *cobra.Command, args []string) error { + project, err := projectOrDefault(projectID) + if err != nil { + return err + } + if cmd.Flags().Changed("release") { + files, err := rest.ListSourceMapFiles(cmd.Context(), project, release) + if err != nil { + return err + } + if jsonOut { + output.JSON(files) + return nil + } + rows := make([][]string, len(files)) + for i, f := range files { + rows[i] = []string{f.File, strconv.FormatInt(f.Bytes, 10), f.UploadedAt} + } + output.Table([]string{"FILE", "BYTES", "UPLOADED"}, rows) + return nil + } + releases, err := rest.ListSourceMapReleases(cmd.Context(), project) + if err != nil { + return err + } + if jsonOut { + output.JSON(releases) + return nil + } + rows := make([][]string, len(releases)) + for i, r := range releases { + name := r.Release + if name == "" { + name = "(no release)" + } + rows[i] = []string{name, strconv.Itoa(r.Files), strconv.FormatInt(r.Bytes, 10), r.LastUploadedAt} + } + output.Table([]string{"RELEASE", "FILES", "BYTES", "LAST UPLOAD"}, rows) + return nil + }, + } + cmd.Flags().StringVarP(&projectID, "project", "p", "", "Project ID (default: the saved default project)") + cmd.Flags().StringVarP(&release, "release", "r", "", "List this release's files") + return cmd +} + +func newSourceMapsDeleteCmd() *cobra.Command { + var projectID, release, file string + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a release's source maps (or one file's)", + RunE: func(cmd *cobra.Command, args []string) error { + project, err := projectOrDefault(projectID) + if err != nil { + return err + } + if !cmd.Flags().Changed("release") { + return errors.New(`--release is required (use --release "" for maps uploaded without one)`) + } + deleted, err := rest.DeleteSourceMaps(cmd.Context(), project, release, file) + if err != nil { + return err + } + if jsonOut { + output.JSON(map[string]int{"deleted": deleted}) + return nil + } + output.Success(fmt.Sprintf("Deleted %d source maps", deleted)) + return nil + }, + } + cmd.Flags().StringVarP(&projectID, "project", "p", "", "Project ID (default: the saved default project)") + cmd.Flags().StringVarP(&release, "release", "r", "", "The release whose maps to delete") + cmd.Flags().StringVar(&file, "file", "", "Delete only this file's map") + return cmd +} diff --git a/internal/cmd/sourcemaps_test.go b/internal/cmd/sourcemaps_test.go new file mode 100644 index 0000000..698c270 --- /dev/null +++ b/internal/cmd/sourcemaps_test.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/flaggr-dev/flaggr-cli/internal/api" +) + +func writeFile(t *testing.T, p, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestFindSourceMapsFilesEachMapUnderItsMinifiedPath(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "chunks/app/page-3f9a.js.map"), "{}") + writeFile(t, filepath.Join(dir, "chunks/main.js.map"), "{}") + writeFile(t, filepath.Join(dir, "chunks/main.js"), "code") + writeFile(t, filepath.Join(dir, "node_modules/dep/x.js.map"), "{}") + single := filepath.Join(t.TempDir(), "server.js.map") + writeFile(t, single, "{}") + + got, err := findSourceMaps([]string{dir, single}, "/_next/static/") + if err != nil { + t.Fatal(err) + } + want := []string{"/_next/static/chunks/app/page-3f9a.js", "/_next/static/chunks/main.js", "/_next/static/server.js"} + if len(got) != len(want) { + t.Fatalf("got %+v", got) + } + for i, w := range want { + if got[i].File != w { + t.Errorf("[%d] = %q, want %q", i, got[i].File, w) + } + } + if none, _ := findSourceMaps([]string{dir}, ""); none[0].File != "/chunks/app/page-3f9a.js" { + t.Errorf("without a prefix: %q", none[0].File) + } +} + +func TestUploadSourceMapSendsTheMapGzippedWithItsTarget(t *testing.T) { + var mu sync.Mutex + var got struct { + method, path, file, release, encoding, auth, body string + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + zr, err := gzip.NewReader(r.Body) + if err != nil { + t.Errorf("not gzip: %v", err) + return + } + body, _ := io.ReadAll(zr) + mu.Lock() + got.method, got.path, got.encoding, got.auth, got.body = r.Method, r.URL.Path, r.Header.Get("Content-Encoding"), r.Header.Get("Authorization"), string(body) + got.file, got.release = r.URL.Query().Get("file"), r.URL.Query().Get("release") + mu.Unlock() + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"release":"web@1","file":"/a.js","bytes":2,"replaced":false}`)) + })) + defer srv.Close() + + p := filepath.Join(t.TempDir(), "a.js.map") + writeFile(t, p, `{"version":3}`) + gz, err := gzipFile(p) + if err != nil { + t.Fatal(err) + } + client := api.NewRESTClient(srv.URL, "fgp_token") + res, err := client.UploadSourceMap(t.Context(), "proj 1", "web@1", "/a.js", gz) + if err != nil { + t.Fatal(err) + } + if got.method != http.MethodPut || got.path != "/api/projects/proj 1/sourcemaps" || got.file != "/a.js" || got.release != "web@1" || + got.encoding != "gzip" || got.auth != "Bearer fgp_token" || got.body != `{"version":3}` || res.File != "/a.js" { + t.Errorf("request = %+v, result = %+v", got, res) + } + if !bytes.HasPrefix(gz, []byte{0x1f, 0x8b}) { + t.Error("gzipFile should gzip") + } +} From 41b4983bddc58626d90073222417120e5515a817 Mon Sep 17 00:00:00 2001 From: Ben Ebsworth Date: Sun, 18 Oct 2026 09:20:28 +1100 Subject: [PATCH 3/4] Require Go 1.27 flaggr-go's errorreport, which crash reports use, needs Go 1.27, so the CLI's go line follows; the README's go install note too. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- go.mod | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2eb014f..80bce0e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ sudo install -m 0755 flaggr /usr/local/bin/flaggr ### With Go -With Go 1.26 or later: +With Go 1.27 or later: ```sh go install github.com/flaggr-dev/flaggr-cli/cmd/flaggr@latest diff --git a/go.mod b/go.mod index a6144ee..a5677d6 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ module github.com/flaggr-dev/flaggr-cli -go 1.26.0 +go 1.27.0 toolchain go1.27.2 require ( connectrpc.com/connect v1.21.0 + github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e github.com/spf13/cobra v1.10.2 google.golang.org/protobuf v1.36.12 ) require ( - github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect ) From 0f6f61cfeba4c9bd4e2a8665149c897f0c3a5e20 Mon Sep 17 00:00:00 2001 From: Ben Ebsworth Date: Sun, 18 Oct 2026 14:45:32 +1100 Subject: [PATCH 4/4] go.mod: flaggr-go v0.2.0 Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a5677d6..a44d7f3 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.27.2 require ( connectrpc.com/connect v1.21.0 - github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e + github.com/flaggr-dev/flaggr-go v0.2.0 github.com/spf13/cobra v1.10.2 google.golang.org/protobuf v1.36.12 ) diff --git a/go.sum b/go.sum index 81cb123..a3875c0 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e h1:vatAAsRLjxmi7mCb025Csofv9DlTEPlvJJ43yLHvDzg= -github.com/flaggr-dev/flaggr-go v0.1.2-0.20261017080633-af804774d76e/go.mod h1:aJQI0Ij2WDfeW7ok7UP9p6XbILlAPulAuwK5sAm7Boc= +github.com/flaggr-dev/flaggr-go v0.2.0 h1:3O7of36hAlbgnNE22izN68IuJRMaQhrpxJVkozxoSnA= +github.com/flaggr-dev/flaggr-go v0.2.0/go.mod h1:tbPxIr9BK9e9EHWGsBM3LZi/kVE2LKrzD3UPy+Fchug= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=