diff --git a/Makefile b/Makefile index e4590dda..5550ee45 100644 --- a/Makefile +++ b/Makefile @@ -29,9 +29,9 @@ integration: run-test-image-locally dev # Compilation dev: - go build -ldflags "-X main.Version=dev-${VERSION}" ./cmd/grr + go build -ldflags "-X github.com/grafana/grizzly/pkg/config.Version=dev-${VERSION}" ./cmd/grr -LDFLAGS := '-s -w -extldflags "-static" -X main.Version=${VERSION}' +LDFLAGS := '-s -w -extldflags "-static" -X github.com/grafana/grizzly/pkg/config.Version=${VERSION}' static: CGO_ENABLED=0 GOOS=linux go build -ldflags=${LDFLAGS} ./cmd/grr diff --git a/cmd/grr/config.go b/cmd/grr/config.go index a433804f..5a8aa9c3 100644 --- a/cmd/grr/config.go +++ b/cmd/grr/config.go @@ -23,6 +23,7 @@ type Opts struct { JsonnetPaths []string Targets []string OutputFormat string + DisableStats bool IsDir bool // used internally to denote that the resource path argument pointed at a directory // Used for supporting resources without envelopes diff --git a/cmd/grr/main.go b/cmd/grr/main.go index c3dabc32..a9b53d24 100644 --- a/cmd/grr/main.go +++ b/cmd/grr/main.go @@ -13,10 +13,6 @@ import ( log "github.com/sirupsen/logrus" ) -// Version is the current version of the grr command. -// To be overwritten at build time -var Version = "dev" - type silentError struct { Err error } @@ -34,7 +30,7 @@ func main() { rootCmd := &cli.Command{ Use: "grr", Short: "Grizzly", - Version: Version, + Version: config.Version, } log.SetFormatter(&log.TextFormatter{ diff --git a/cmd/grr/selfupdate.go b/cmd/grr/selfupdate.go index c18a5aff..704fe655 100644 --- a/cmd/grr/selfupdate.go +++ b/cmd/grr/selfupdate.go @@ -8,6 +8,7 @@ import ( "github.com/go-clix/cli" "github.com/grafana/grizzly/internal/grizzly" + "github.com/grafana/grizzly/pkg/config" ) func selfUpdateCmd() *cli.Command { @@ -21,12 +22,12 @@ func selfUpdateCmd() *cli.Command { cmd.Run = func(cmd *cli.Command, args []string) error { updater := grizzly.NewSelfUpdater(http.DefaultClient) - newVersion, err := updater.UpdateSelf(context.Background(), Version) + newVersion, err := updater.UpdateSelf(context.Background(), config.Version) if errors.Is(err, grizzly.ErrNextVersionIsMajorBump) { - return fmt.Errorf("self-update aborted as the next version (%[1]s) is a major bump from the current one (%[2]s). Please update manually: https://github.com/grafana/grizzly/releases/tag/%[1]s", newVersion, Version) + return fmt.Errorf("self-update aborted as the next version (%[1]s) is a major bump from the current one (%[2]s). Please update manually: https://github.com/grafana/grizzly/releases/tag/%[1]s", newVersion, config.Version) } if errors.Is(err, grizzly.ErrCurrentVersionIsLatest) { - fmt.Printf("Current version is the latest: %s\n", Version) + fmt.Printf("Current version is the latest: %s\n", config.Version) return nil } if err != nil { diff --git a/cmd/grr/workflow.go b/cmd/grr/workflow.go index ed3622c7..500bca50 100644 --- a/cmd/grr/workflow.go +++ b/cmd/grr/workflow.go @@ -98,9 +98,8 @@ func pullCmd(registry grizzly.Registry) *cli.Command { cmd.Flags().BoolVarP(&continueOnError, "continue-on-error", "e", false, "don't stop pulling on error") - eventsRecorder := grizzly.NewWriterRecorder(os.Stdout, getEventFormatter()) - cmd.Run = func(cmd *cli.Command, args []string) error { + eventsRecorder := getEventsRecorder(opts) format, onlySpec, err := getOutputFormat(opts) if err != nil { return err @@ -219,9 +218,8 @@ func applyCmd(registry grizzly.Registry) *cli.Command { cmd.Flags().BoolVarP(&continueOnError, "continue-on-error", "e", false, "don't stop apply on first error") - eventsRecorder := grizzly.NewWriterRecorder(os.Stdout, getEventFormatter()) - cmd.Run = func(cmd *cli.Command, args []string) error { + eventsRecorder := getEventsRecorder(opts) resourceKind, folderUID, err := getOnlySpec(opts) if err != nil { return err @@ -523,6 +521,8 @@ func initialiseCmd(cmd *cli.Command, opts *Opts) *cli.Command { cmd.Flags().StringSliceVarP(&opts.JsonnetPaths, "jpath", "J", getDefaultJsonnetFolders(), "Specify an additional library search dir (right-most wins)") cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", "Output format") + cmd.Flags().BoolVar(&opts.DisableStats, "disable-reporting", false, "disable sending of anonymous usage stats to Grafana Labs") + return initialiseLogging(cmd, &opts.LoggingOpts) } @@ -558,6 +558,15 @@ func initialiseLogging(cmd *cli.Command, loggingOpts *LoggingOpts) *cli.Command func getDefaultJsonnetFolders() []string { return []string{"vendor", "lib", "."} } + +func getEventsRecorder(opts Opts) grizzly.EventsRecorder { + wr := grizzly.NewWriterRecorder(os.Stdout, getEventFormatter()) + if opts.DisableStats || config.UsageStatsDisabled() { + return wr + } + return grizzly.NewUsageRecorder(wr) +} + func getOutputFormat(opts Opts) (string, bool, error) { var onlySpec bool context, err := config.CurrentContext() diff --git a/docs/config.yaml b/docs/config.yaml index 251b1d46..857f1b6e 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -49,9 +49,12 @@ menu: - name: Hidden Elements url: /hidden-elements/ weight: 11 + - name: Usage statistics + url: /usage-statistics/ + weight: 12 - name: GitHub url: https://github.com/grafana/grizzly/ - weight: 12 + weight: 13 markup: defaultMarkdownHandler: goldmark diff --git a/docs/content/usage-statistics.md b/docs/content/usage-statistics.md new file mode 100644 index 00000000..96d115c5 --- /dev/null +++ b/docs/content/usage-statistics.md @@ -0,0 +1,25 @@ +--- +date: "2024-10-24T00:00:00+00:00" +title: "Usage statistics" +--- + +By default, Grizzly sends anonymous, but uniquely identifiable usage information to Grafana Labs. These statistics are sent to stats.grafana.org. + +Statistics help Grafana better understand how Grizzly is used. This helps us prioritize features and documentation. + +The usage information includes the following details: + +* A hash of your config file +* Timestamp of when the report was created +* The version of Grizzly. +* The operating system Grizzly is running on. +* The system architecture Grizzly is running on. +* The operation performed (`pull`/`apply`) +* The number of resources affected + +This list may change over time. +For auditing, the code performing this reporting can be found at the end of the [events.go](https://github.com/grafana/grizzly/blob/main/pkg/grizzly/events.go) file. + +## Opt-out of data collection + +You can use the `--disable-reporting` command line flag to disable the reporting and opt-out of the data collection. diff --git a/pkg/config/config.go b/pkg/config/config.go index 9d4dceb3..18b3e97c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "crypto/sha256" "encoding/json" "fmt" "os" @@ -16,9 +17,14 @@ import ( ) const ( - CurrentContextSetting = "current-context" + CurrentContextSetting = "current-context" + DisableReportingSetting = "disable-reporting" ) +// Version is the current version of the grr command. +// To be overwritten at build time +var Version = "dev" + func Initialise() { viper.SetConfigName("settings") viper.SetConfigType("yaml") @@ -142,6 +148,10 @@ func UseContext(context string) error { return fmt.Errorf("context %s not found", context) } +func UsageStatsDisabled() bool { + return viper.GetBool(DisableReportingSetting) +} + func CurrentContext() (*Context, error) { name := viper.GetString(CurrentContextSetting) if name == "" { @@ -183,6 +193,16 @@ var acceptableKeys = map[string]string{ "only-spec": "bool", } +func Hash() (string, error) { + cfg := viper.AllSettings() + out := sha256.New() + err := json.NewEncoder(out).Encode(cfg) + if err != nil { + return "", fmt.Errorf("writing to hash") + } + return fmt.Sprintf("%X", out.Sum(nil)), nil +} + func Get(path, outputFormat string) (string, error) { ctx := viper.GetString(CurrentContextSetting) diff --git a/pkg/grizzly/events.go b/pkg/grizzly/events.go index 16d300ac..8687faa0 100644 --- a/pkg/grizzly/events.go +++ b/pkg/grizzly/events.go @@ -1,11 +1,20 @@ package grizzly import ( + "bytes" + "context" + "encoding/json" "fmt" "io" + "net/http" + "runtime" "strings" + "time" "github.com/fatih/color" + "github.com/grafana/grizzly/pkg/config" + log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" ) type EventSeverity uint8 @@ -114,3 +123,76 @@ func (recorder *WriterRecorder) Record(event Event) { func (recorder *WriterRecorder) Summary() Summary { return *recorder.summary } + +var _ EventsRecorder = (*WriterRecorder)(nil) + +type UsageRecorder struct { + wr *WriterRecorder + endpoint string +} + +// Record implements EventsRecorder. +func (u *UsageRecorder) Record(event Event) { + u.wr.Record(event) +} + +// Summary implements EventsRecorder. +func (u *UsageRecorder) Summary() Summary { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + group, ctx := errgroup.WithContext(ctx) + for op, n := range u.wr.summary.EventCounts { + group.Go(func() error { + return u.reportUsage(ctx, op.ID, n) + }) + } + err := group.Wait() + if err != nil { + log.Debugf("failed to send usage stats: %v", err) + } + + return u.wr.Summary() +} + +func (u *UsageRecorder) reportUsage(ctx context.Context, op string, n int) error { + var buff bytes.Buffer + configHash, err := config.Hash() + if err != nil { + configHash = "failed-to-hash-config" + } + err = json.NewEncoder(&buff).Encode(map[string]interface{}{ + "uuid": configHash, + "arch": runtime.GOARCH, + "os": runtime.GOOS, + "resources": n, + "operation": op, + "createdAt": time.Now(), + "version": config.Version, + }) + if err != nil { + return fmt.Errorf("encoding usage report") + } + req, err := http.NewRequest(http.MethodPost, u.endpoint, &buff) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.Header.Add("content-type", "application/json") + req = req.WithContext(ctx) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("sending post request: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("expected OK, got %s", resp.Status) + } + return nil +} + +var _ EventsRecorder = (*UsageRecorder)(nil) + +func NewUsageRecorder(wr *WriterRecorder) *UsageRecorder { + return &UsageRecorder{ + wr: wr, + endpoint: "https://stats.grafana.org/grizzly-usage-report", + } +} diff --git a/pkg/grizzly/workflow.go b/pkg/grizzly/workflow.go index dfde05b0..526c9182 100644 --- a/pkg/grizzly/workflow.go +++ b/pkg/grizzly/workflow.go @@ -177,7 +177,7 @@ func listWide(listedResources []listedResource) ([]byte, error) { // Pull pulls remote resources and stores them in the local file system. // The given resourcePath must be a directory, where all resources will be stored. // If opts.JSONSpec is true, which is only applicable for dashboards, saves the spec as a JSON file. -func Pull(registry Registry, resourcePath string, onlySpec bool, outputFormat string, targets []string, continueOnError bool, eventsRecorder eventsRecorder) error { +func Pull(registry Registry, resourcePath string, onlySpec bool, outputFormat string, targets []string, continueOnError bool, eventsRecorder EventsRecorder) error { resourcePathIsFile, err := isFile(resourcePath) if err != nil { return err @@ -377,12 +377,13 @@ func Diff(registry Registry, resources Resources, onlySpec bool, outputFormat st return nil } -type eventsRecorder interface { +type EventsRecorder interface { Record(event Event) + Summary() Summary } // Apply pushes resources to endpoints -func Apply(registry Registry, resources Resources, continueOnError bool, eventsRecorder eventsRecorder) error { +func Apply(registry Registry, resources Resources, continueOnError bool, eventsRecorder EventsRecorder) error { var finalErr error for _, resource := range resources.AsList() { @@ -405,7 +406,7 @@ func Apply(registry Registry, resources Resources, continueOnError bool, eventsR return finalErr } -func applyResource(registry Registry, resource Resource, trailRecorder eventsRecorder) error { +func applyResource(registry Registry, resource Resource, trailRecorder EventsRecorder) error { resourceRef := resource.Ref().String() handler, err := registry.GetHandler(resource.Kind()) @@ -489,7 +490,7 @@ func Snapshot(registry Registry, resources Resources, expiresSeconds int) error // Watch watches a directory for changes then pushes Jsonnet resource to endpoints // when changes are noticed. -func Watch(registry Registry, watchDir string, resourcePath string, parser Parser, parserOpts ParserOptions, trailRecorder eventsRecorder) error { +func Watch(registry Registry, watchDir string, resourcePath string, parser Parser, parserOpts ParserOptions, trailRecorder EventsRecorder) error { updateWatchedResource := func(path string) error { log.Infof("Changes detected in %q. Applying %q", path, resourcePath) resources, err := parser.Parse(resourcePath, parserOpts)