From 0e4e752a191c443926a48ddf6934d0b0bc1442fe Mon Sep 17 00:00:00 2001 From: Nageshbansal <76246968+Nageshbansal@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:07:37 +0530 Subject: [PATCH] feat: introduce ledger and version (#87) #### Features - Introduce ledger for gathering analytics abt usage of foundryctl - Adds version command Related: https://github.com/SigNoz/platform-pod/issues/1910 --- .github/workflows/build.yaml | 37 +++++++++- .goreleaser.yaml | 5 ++ Makefile | 13 ++-- cmd/foundryctl/cast.go | 25 +++++-- cmd/foundryctl/catalog.go | 36 +++++++-- cmd/foundryctl/config.go | 6 +- cmd/foundryctl/forge.go | 15 +++- cmd/foundryctl/gauge.go | 14 +++- cmd/foundryctl/gen.go | 26 +++++-- cmd/foundryctl/main.go | 20 +++++ cmd/foundryctl/version.go | 36 +++++++++ docs/ledger.md | 51 +++++++++++++ go.mod | 3 + go.sum | 6 ++ internal/ledger/config.go | 89 +++++++++++++++++++++++ internal/ledger/ledger.go | 13 ++++ internal/ledger/noopledger/provider.go | 18 +++++ internal/ledger/segmentledger/provider.go | 69 ++++++++++++++++++ internal/version/version.go | 74 +++++++++++++++++++ 19 files changed, 527 insertions(+), 29 deletions(-) create mode 100644 cmd/foundryctl/version.go create mode 100644 docs/ledger.md create mode 100644 internal/ledger/config.go create mode 100644 internal/ledger/ledger.go create mode 100644 internal/ledger/noopledger/provider.go create mode 100644 internal/ledger/segmentledger/provider.go create mode 100644 internal/version/version.go diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9554a92..34a1c24 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,15 +8,50 @@ permissions: contents: write id-token: write +env: + MAKE: make --no-print-directory --makefile=.primus/src/make/main.mk + jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.build-info.outputs.version }} + hash: ${{ steps.build-info.outputs.hash }} + steps: + - name: self-checkout + uses: actions/checkout@v4 + - id: token + name: github-token-gen + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PRIMUS_APP_ID }} + private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: primus-checkout + uses: actions/checkout@v4 + with: + repository: signoz/primus + ref: main + path: .primus + token: ${{ steps.token.outputs.token }} + - name: build-info + id: build-info + run: | + echo "version=$($MAKE info-version)" >> $GITHUB_OUTPUT + echo "hash=$($MAKE info-commit-short)" >> $GITHUB_OUTPUT docker: uses: signoz/primus.workflows/.github/workflows/go-build.yaml@main + needs: [prepare] secrets: inherit with: PRIMUS_REF: main GO_BUILD_CONTEXT: ./cmd/foundryctl GO_VERSION: 1.25 - GO_BUILD_FLAGS: "" + GO_BUILD_FLAGS: >- + -ldflags='-s -w + -X github.com/signoz/foundry/internal/version.version=${{ needs.prepare.outputs.version }} + -X github.com/signoz/foundry/internal/version.hash=${{ needs.prepare.outputs.hash }} + -X github.com/signoz/foundry/internal/ledger.key=YPscRxkyHv2Fn0aOucmLrMYBBVAC1AFu" GO_NAME: foundryctl DOCKER_BASE_IMAGES: '{"distroless":"gcr.io/distroless/base:latest"}' DOCKER_DOCKERFILE_PATH: Dockerfile.multi-arch diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b259452..1a2221c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -24,6 +24,11 @@ builds: goarm64: - v8.0 mod_timestamp: "{{ .CommitTimestamp }}" + ldflags: + - -s -w + - -X github.com/signoz/foundry/internal/version.version=v{{ .Version }} + - -X github.com/signoz/foundry/internal/version.hash={{ .ShortCommit }} + - -X github.com/signoz/foundry/internal/ledger.key=YPscRxkyHv2Fn0aOucmLrMYBBVAC1AFu tags: - timetzdata diff --git a/Makefile b/Makefile index a9260d6..fe93f0a 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ FOUNDRYCTL := go run ./cmd/foundryctl GOTMPL := go run go.opentelemetry.io/build-tools/gotmpl@latest CASTINGS_JSON = $$(cat docs/examples/castings.json) +NO_LEDGER := "--no-ledger" clean: cd pours/deployment && docker compose -p dev down --remove-orphans --volumes @@ -10,26 +11,26 @@ clean: rm -rf ./pours gauge: - $(FOUNDRYCTL) gauge --debug -f ./tmp/casting.yaml + $(FOUNDRYCTL) gauge --debug $(NO_LEDGER) -f ./tmp/casting.yaml forge: - $(FOUNDRYCTL) forge --debug -f ./tmp/casting.yaml + $(FOUNDRYCTL) forge --debug $(NO_LEDGER) -f ./tmp/casting.yaml cast: - $(FOUNDRYCTL) cast --debug -f ./tmp/casting.yaml + $(FOUNDRYCTL) cast --debug $(NO_LEDGER) -f ./tmp/casting.yaml test: make forge make docker gen-examples: - $(FOUNDRYCTL) gen --debug examples + $(FOUNDRYCTL) gen --debug $(NO_LEDGER) examples gen-schemas: - $(FOUNDRYCTL) gen --debug schemas + $(FOUNDRYCTL) gen --debug $(NO_LEDGER) schemas gen-docs: - $(FOUNDRYCTL) catalog --debug --format json --output "docs/examples/castings.json" + $(FOUNDRYCTL) catalog --debug $(NO_LEDGER) --format json --output "docs/examples/castings.json" $(GOTMPL) -b README.md.gotmpl -d "$(CASTINGS_JSON)" -o README.md $(GOTMPL) -b docs/examples/README.md.gotmpl -d "$(CASTINGS_JSON)" -o docs/examples/README.md diff --git a/cmd/foundryctl/cast.go b/cmd/foundryctl/cast.go index 4a53414..6e107d1 100644 --- a/cmd/foundryctl/cast.go +++ b/cmd/foundryctl/cast.go @@ -10,6 +10,7 @@ import ( foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/foundry" "github.com/signoz/foundry/internal/instrumentation" + "github.com/signoz/foundry/internal/ledger" "github.com/spf13/cobra" ) @@ -20,22 +21,26 @@ func registerCastCmd(rootCmd *cobra.Command) { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() logger := instrumentation.NewLogger(commonCfg.Debug) + tracker := newTracker() + defer func() { + _ = tracker.Close() + }() if !castCfg.NoGauge { - err := runGauge(ctx, logger, commonCfg.File) + err := runGauge(ctx, logger, tracker, commonCfg.File) if err != nil { return err } } if !castCfg.NoForge { - err := runForge(ctx, logger, commonCfg.File, poursCfg.Path) + err := runForge(ctx, logger, tracker, commonCfg.File, poursCfg.Path) if err != nil { return err } } - return runCast(ctx, logger, poursCfg.Path, commonCfg.File) + return runCast(ctx, logger, tracker, poursCfg.Path, commonCfg.File) }, } @@ -43,7 +48,7 @@ func registerCastCmd(rootCmd *cobra.Command) { castCfg.RegisterFlags(castCmd) } -func runCast(ctx context.Context, logger *slog.Logger, poursPath string, configPath string) error { +func runCast(ctx context.Context, logger *slog.Logger, tracker ledger.Ledger, poursPath string, configPath string) error { foundry, err := foundry.New(logger) if err != nil { logger.ErrorContext(ctx, "failed to create foundry, please report this issues to developers at https://github.com/signoz/foundry/issues", foundryerrors.LogAttr(err)) @@ -59,8 +64,18 @@ func runCast(ctx context.Context, logger *slog.Logger, poursPath string, configP lock, err := foundry.Config.GetV1Alpha1Lock(ctx, configPath) if err != nil { logger.ErrorContext(ctx, "failed to load generated casting.yaml.lock", foundryerrors.LogAttr(err)) + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("cast"), err)) return err } - return foundry.Cast(ctx, lock, poursPath) + props := ledger.CastingProperties("cast", lock) + + err = foundry.Cast(ctx, lock, poursPath) + if err != nil { + tracker.Track(ctx, ledger.WithError(props, err)) + return err + } + + tracker.Track(ctx, ledger.WithSuccess(props)) + return nil } diff --git a/cmd/foundryctl/catalog.go b/cmd/foundryctl/catalog.go index ba6e399..068e170 100644 --- a/cmd/foundryctl/catalog.go +++ b/cmd/foundryctl/catalog.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "log/slog" "os" @@ -10,6 +11,7 @@ import ( "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/internal/foundry" "github.com/signoz/foundry/internal/instrumentation" + "github.com/signoz/foundry/internal/ledger" "github.com/spf13/cobra" ) @@ -19,8 +21,12 @@ func registerCatalogCmd(rootCmd *cobra.Command) { Short: "Show available castings", RunE: func(cmd *cobra.Command, args []string) error { logger := instrumentation.NewLogger(commonCfg.Debug) + tracker := newTracker() + defer func() { + _ = tracker.Close() + }() - return runCatalog(logger) + return runCatalog(cmd.Context(), logger, tracker) }, } @@ -61,9 +67,10 @@ func catalogGroup(e castingEntry) int { } } -func runCatalog(logger *slog.Logger) error { +func runCatalog(ctx context.Context, logger *slog.Logger, tracker ledger.Ledger) error { f, err := foundry.New(logger) if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("catalog"), err)) return err } @@ -88,13 +95,25 @@ func runCatalog(logger *slog.Logger) error { if catalogCfg.Format == "json" { data, err := json.MarshalIndent(map[string]any{"Castings": entries}, "", " ") if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("catalog"), err)) return err } if catalogCfg.OutPath != "" { - return os.WriteFile(catalogCfg.OutPath, data, 0644) + err = os.WriteFile(catalogCfg.OutPath, data, 0644) + if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("catalog"), err)) + return err + } + tracker.Track(ctx, ledger.WithSuccess(ledger.CommandProperties("catalog"))) + return nil } _, err = os.Stdout.Write(data) - return err + if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("catalog"), err)) + return err + } + tracker.Track(ctx, ledger.WithSuccess(ledger.CommandProperties("catalog"))) + return nil } table := tablewriter.NewWriter(os.Stdout) @@ -103,5 +122,12 @@ func runCatalog(logger *slog.Logger) error { _ = table.Append(e.Mode, e.Flavor, e.Platform, e.Example) } - return table.Render() + err = table.Render() + if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("catalog"), err)) + return err + } + + tracker.Track(ctx, ledger.WithSuccess(ledger.CommandProperties("catalog"))) + return nil } diff --git a/cmd/foundryctl/config.go b/cmd/foundryctl/config.go index 2d6129c..564c5da 100644 --- a/cmd/foundryctl/config.go +++ b/cmd/foundryctl/config.go @@ -17,13 +17,15 @@ var ( ) type commonConfig struct { - File string - Debug bool + File string + Debug bool + NoLedger bool } func (c *commonConfig) RegisterFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringVarP(&c.File, "file", "f", "casting.yaml", "Path to the casting configuration file.") cmd.PersistentFlags().BoolVarP(&c.Debug, "debug", "d", false, "Enable debug mode.") + cmd.PersistentFlags().BoolVar(&c.NoLedger, "no-ledger", false, "Disable anonymous usage ledger.") } type poursConfig struct { diff --git a/cmd/foundryctl/forge.go b/cmd/foundryctl/forge.go index 7f5313b..c367c2a 100644 --- a/cmd/foundryctl/forge.go +++ b/cmd/foundryctl/forge.go @@ -9,6 +9,7 @@ import ( foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/foundry" "github.com/signoz/foundry/internal/instrumentation" + "github.com/signoz/foundry/internal/ledger" "github.com/signoz/foundry/internal/writer" "github.com/spf13/cobra" ) @@ -21,15 +22,19 @@ func registerForgeCmd(rootCmd *cobra.Command) { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() logger := instrumentation.NewLogger(commonCfg.Debug) + tracker := newTracker() + defer func() { + _ = tracker.Close() + }() - return runForge(ctx, logger, commonCfg.File, poursCfg.Path) + return runForge(ctx, logger, tracker, commonCfg.File, poursCfg.Path) }, } rootCmd.AddCommand(forgeCmd) } -func runForge(ctx context.Context, logger *slog.Logger, path string, poursPath string) error { +func runForge(ctx context.Context, logger *slog.Logger, tracker ledger.Ledger, path string, poursPath string) error { foundry, err := foundry.New(logger) if err != nil { logger.ErrorContext(ctx, "failed to create foundry, please report this issues to developers at https://github.com/signoz/foundry/issues", foundryerrors.LogAttr(err)) @@ -38,18 +43,24 @@ func runForge(ctx context.Context, logger *slog.Logger, path string, poursPath s config, err := foundry.Config.GetV1Alpha1(ctx, path) if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("forge"), err)) return err } + props := ledger.CastingProperties("forge", config) + poursAbsPath, err := filepath.Abs(poursPath) if err != nil { + tracker.Track(ctx, ledger.WithError(props, err)) return err } err = foundry.Forge(ctx, config, path, &writer.Options{Output: &os.File{}, TargetDirectory: poursAbsPath}) if err != nil { + tracker.Track(ctx, ledger.WithError(props, err)) return err } + tracker.Track(ctx, ledger.WithSuccess(props)) return nil } diff --git a/cmd/foundryctl/gauge.go b/cmd/foundryctl/gauge.go index ecbf943..777f807 100644 --- a/cmd/foundryctl/gauge.go +++ b/cmd/foundryctl/gauge.go @@ -7,6 +7,7 @@ import ( foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/foundry" "github.com/signoz/foundry/internal/instrumentation" + "github.com/signoz/foundry/internal/ledger" "github.com/spf13/cobra" ) @@ -17,15 +18,19 @@ func registerGaugeCmd(rootCmd *cobra.Command) { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() logger := instrumentation.NewLogger(commonCfg.Debug) + tracker := newTracker() + defer func() { + _ = tracker.Close() + }() - return runGauge(ctx, logger, commonCfg.File) + return runGauge(ctx, logger, tracker, commonCfg.File) }, } rootCmd.AddCommand(gaugeCmd) } -func runGauge(ctx context.Context, logger *slog.Logger, path string) error { +func runGauge(ctx context.Context, logger *slog.Logger, tracker ledger.Ledger, path string) error { foundry, err := foundry.New(logger) if err != nil { logger.ErrorContext(ctx, "failed to create foundry, please report this issues to developers at https://github.com/signoz/foundry/issues", foundryerrors.LogAttr(err)) @@ -35,14 +40,19 @@ func runGauge(ctx context.Context, logger *slog.Logger, path string) error { casting, err := foundry.Config.GetV1Alpha1(ctx, path) if err != nil { logger.ErrorContext(ctx, err.Error()) + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("gauge"), err)) return err } + props := ledger.CastingProperties("gauge", casting) + err = foundry.Gauge(ctx, casting) if err != nil { logger.ErrorContext(ctx, err.Error()) + tracker.Track(ctx, ledger.WithError(props, err)) return err } + tracker.Track(ctx, ledger.WithSuccess(props)) return nil } diff --git a/cmd/foundryctl/gen.go b/cmd/foundryctl/gen.go index 151f520..9f970d2 100644 --- a/cmd/foundryctl/gen.go +++ b/cmd/foundryctl/gen.go @@ -11,6 +11,7 @@ import ( foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/foundry" "github.com/signoz/foundry/internal/instrumentation" + "github.com/signoz/foundry/internal/ledger" "github.com/signoz/foundry/internal/types" "github.com/spf13/cobra" "github.com/swaggest/jsonschema-go" @@ -35,8 +36,12 @@ func registerGenExamples(rootCmd *cobra.Command) { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() logger := instrumentation.NewLogger(commonCfg.Debug) + tracker := newTracker() + defer func() { + _ = tracker.Close() + }() - return runGenExamples(ctx, logger) + return runGenExamples(ctx, logger, tracker) }, } @@ -49,19 +54,23 @@ func registerGenSchemas(rootCmd *cobra.Command) { Short: "Generate schema files.", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - logger := instrumentation.NewLogger(commonCfg.Debug) + tracker := newTracker() + defer func() { + _ = tracker.Close() + }() - return runGenSchemas(ctx, logger) + return runGenSchemas(ctx, tracker) }, } rootCmd.AddCommand(genSchemasCmd) } -func runGenExamples(ctx context.Context, logger *slog.Logger) error { +func runGenExamples(ctx context.Context, logger *slog.Logger, tracker ledger.Ledger) error { foundry, err := foundry.New(logger) if err != nil { logger.ErrorContext(ctx, "failed to create foundry, please report this issues to developers at https://github.com/signoz/foundry/issues", foundryerrors.LogAttr(err)) + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("gen.examples"), err)) return err } @@ -82,27 +91,32 @@ func runGenExamples(ctx context.Context, logger *slog.Logger) error { return err } - err = runForge(ctx, logger, filepath.Join(rootPath, "casting.yaml"), filepath.Join(rootPath, "pours")) + err = runForge(ctx, logger, tracker, filepath.Join(rootPath, "casting.yaml"), filepath.Join(rootPath, "pours")) if err != nil { logger.ErrorContext(ctx, "failed to forge casting", slog.Any("deployment", deployment), foundryerrors.LogAttr(err)) continue } } + tracker.Track(ctx, ledger.WithSuccess(ledger.CommandProperties("gen.examples"))) return nil } -func runGenSchemas(context.Context, *slog.Logger) error { + +func runGenSchemas(ctx context.Context, tracker ledger.Ledger) error { reflector := jsonschema.Reflector{} schema, err := reflector.Reflect(v1alpha1.Casting{}) if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("gen.schemas"), err)) log.Fatal(err) } err = os.WriteFile(filepath.Join("docs", "schemas", "v1alpha1.yaml"), types.MustMarshalYAML(schema), 0644) if err != nil { + tracker.Track(ctx, ledger.WithError(ledger.CommandProperties("gen.schemas"), err)) return err } + tracker.Track(ctx, ledger.WithSuccess(ledger.CommandProperties("gen.schemas"))) return nil } diff --git a/cmd/foundryctl/main.go b/cmd/foundryctl/main.go index 8291186..2d24159 100644 --- a/cmd/foundryctl/main.go +++ b/cmd/foundryctl/main.go @@ -6,6 +6,9 @@ import ( foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/instrumentation" + "github.com/signoz/foundry/internal/ledger" + "github.com/signoz/foundry/internal/ledger/noopledger" + "github.com/signoz/foundry/internal/ledger/segmentledger" "github.com/spf13/cobra" ) @@ -29,6 +32,7 @@ func main() { registerCastCmd(rootCmd) registerGenCmd(rootCmd) registerCatalogCmd(rootCmd) + registerVersionCmd(rootCmd) logger := instrumentation.NewLogger(false) @@ -37,3 +41,19 @@ func main() { os.Exit(1) } } + +// newTracker creates a Ledger based on config. +// Returns a no-op ledger when --no-ledger is passed. +func newTracker() ledger.Ledger { + config := ledger.NewConfig() + if commonCfg.NoLedger { + config.Enabled = false + } + + switch config.Provider() { + case "segment": + return segmentledger.New(config) + default: + return noopledger.New() + } +} diff --git a/cmd/foundryctl/version.go b/cmd/foundryctl/version.go new file mode 100644 index 0000000..2bbb647 --- /dev/null +++ b/cmd/foundryctl/version.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os" + + "github.com/signoz/foundry/internal/version" + "github.com/spf13/cobra" +) + +var versionCfg versionConfig + +type versionConfig struct { + Short bool +} + +func (c *versionConfig) RegisterFlags(cmd *cobra.Command) { + cmd.Flags().BoolVar(&c.Short, "short", false, "Print version in a single line") +} + +func registerVersionCmd(rootCmd *cobra.Command) { + versionCmd := &cobra.Command{ + Use: "version", + Short: "Print the version information", + Run: func(cmd *cobra.Command, args []string) { + if versionCfg.Short { + _, _ = fmt.Fprintln(os.Stdout, version.Info.Short()) + return + } + version.Info.PrettyPrint() + }, + } + + versionCfg.RegisterFlags(versionCmd) + rootCmd.AddCommand(versionCmd) +} diff --git a/docs/ledger.md b/docs/ledger.md new file mode 100644 index 0000000..bc55e94 --- /dev/null +++ b/docs/ledger.md @@ -0,0 +1,51 @@ +# Ledger + +Foundryctl maintains an anonymous usage ledger to help the SigNoz team understand how the tool is used, identify common errors, and prioritize improvements. **No personally identifiable information (PII) is collected.** + +## What is collected + +Each command execution sends a single `foundryctl` event with the following properties: + +| Property | Description | Example | +|---|---|---| +| `command` | The command that was run | `forge`, `cast` | +| `platform` | Deployment platform from casting.yaml | `aws`, `docker`, `linux` | +| `mode` | Deployment mode | `docker`, `systemd`, `kubernetes` | +| `flavor` | Deployment flavor | `compose`, `binary`, `helm` | +| `patches_configured` | Whether patches are defined | `true` / `false` | +| `patch_count` | Number of patch entries | `0`, `2` | +| `infrastructure_enabled` | Whether IaC generation is enabled | `true` / `false` | +| `metastore_kind` | MetaStore backend type | `postgres`, `sqlite` | +| `telemetry_store_kind` | TelemetryStore backend type | `clickhouse` | +| `telemetry_keeper_kind` | TelemetryKeeper backend type | `clickhousekeeper` | +| `success` | Whether the command succeeded | `true` / `false` | +| `error` | Error message (on failure only) | `missing tool: docker` | +| `os` | Operating system | `linux`, `darwin` | +| `arch` | CPU architecture | `amd64`, `arm64` | +| `foundry_version` | foundryctl version | `0.1.0` | + +### Identity + +Events are attributed to the machine hostname as an anonymous identifier. No usernames, emails, IP addresses, or file contents are sent. + +## Tracked commands + +All commands send the same `foundryctl` event, differentiated by the `command` property: + +- `gauge` +- `forge` +- `cast` +- `gen.examples` +- `gen.schemas` +- `catalog` + +## How to disable the ledger + +### Per-command + +Use the `--no-ledger` flag on any command: + +```bash +foundryctl forge --no-ledger +foundryctl --no-ledger cast +``` diff --git a/go.mod b/go.mod index b7cf361..0a5784c 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/evanphx/json-patch/v5 v5.9.11 github.com/olekukonko/tablewriter v1.1.4 + github.com/segmentio/analytics-go/v3 v3.3.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/swaggest/jsonschema-go v0.3.79 @@ -28,6 +29,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect @@ -91,6 +93,7 @@ require ( github.com/rubenv/sql-migrate v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/segmentio/backo-go v1.0.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.7.0 // indirect diff --git a/go.sum b/go.sum index d480ef9..e7ead8d 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= @@ -256,6 +258,10 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/analytics-go/v3 v3.3.0 h1:8VOMaVGBW03pdBrj1CMFfY9o/rnjJC+1wyQHlVxjw5o= +github.com/segmentio/analytics-go/v3 v3.3.0/go.mod h1:p8owAF8X+5o27jmvUognuXxdtqvSGtD0ZrfY2kcS9bE= +github.com/segmentio/backo-go v1.0.0 h1:kbOAtGJY2DqOR0jfRkYEorx/b18RgtepGtY3+Cpe6qA= +github.com/segmentio/backo-go v1.0.0/go.mod h1:kJ9mm9YmoWSkk+oQ+5Cj8DEoRCX2JT6As4kEtIIOp1M= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= diff --git a/internal/ledger/config.go b/internal/ledger/config.go new file mode 100644 index 0000000..ff9ab44 --- /dev/null +++ b/internal/ledger/config.go @@ -0,0 +1,89 @@ +package ledger + +import "github.com/signoz/foundry/api/v1alpha1" + +// key is the Segment write key, set via ldflags at build time. +// Example: -ldflags "-X github.com/signoz/foundry/internal/ledger.key=". +var key string = "" + +// Config holds ledger configuration. +type Config struct { + Enabled bool + Segment Segment +} + +// Segment holds Segment-specific configuration. +type Segment struct { + Key string +} + +// NewConfig returns the default ledger configuration. +// The Segment write key is populated from the ldflags-injected value. +func NewConfig() Config { + return Config{ + Enabled: true, + Segment: Segment{ + Key: key, + }, + } +} + +// Provider returns the provider name based on the configuration. +func (c Config) Provider() string { + if c.Enabled { + return "segment" + } + return "noop" +} + +// Property keys for casting details. +const ( + PropCommand = "command" + PropPlatform = "platform" + PropMode = "mode" + PropFlavor = "flavor" + PropPatchesConfigured = "patches_configured" + PropPatchCount = "patch_count" + PropInfrastructureEnabled = "infrastructure_enabled" + PropMetaStoreKind = "metastore_kind" + PropTelemetryStoreKind = "telemetry_store_kind" + PropTelemetryKeeperKind = "telemetry_keeper_kind" + PropSuccess = "success" + PropError = "error" +) + +// CommandProperties returns a minimal property map with just the command name. +func CommandProperties(command string) map[string]any { + return map[string]any{ + PropCommand: command, + } +} + +// CastingProperties extracts trackable properties from a Casting config. +func CastingProperties(command string, casting v1alpha1.Casting) map[string]any { + return map[string]any{ + PropCommand: command, + PropPlatform: casting.Spec.Deployment.Platform, + PropMode: casting.Spec.Deployment.Mode, + PropFlavor: casting.Spec.Deployment.Flavor, + PropPatchesConfigured: len(casting.Spec.Patches) > 0, + PropPatchCount: len(casting.Spec.Patches), + PropInfrastructureEnabled: casting.Spec.Infrastructure.Enabled, + PropMetaStoreKind: casting.Spec.MetaStore.Kind.String(), + PropTelemetryStoreKind: casting.Spec.TelemetryStore.Kind.String(), + PropTelemetryKeeperKind: casting.Spec.TelemetryKeeper.Kind.String(), + } +} + +// WithSuccess adds success=true to the properties. +func WithSuccess(props map[string]any) map[string]any { + props[PropSuccess] = true + return props +} + +// WithError adds success=false and the error message to the properties. +func WithError(props map[string]any, err error) map[string]any { + props[PropSuccess] = false + props[PropError] = err.Error() + return props +} diff --git a/internal/ledger/ledger.go b/internal/ledger/ledger.go new file mode 100644 index 0000000..c7af658 --- /dev/null +++ b/internal/ledger/ledger.go @@ -0,0 +1,13 @@ +// Package ledger provides anonymous usage tracking for foundryctl commands. +package ledger + +import "context" + +// Ledger is the interface for tracking CLI usage events. +type Ledger interface { + // Track records a single foundryctl event with the given properties. + Track(ctx context.Context, properties map[string]any) + + // Close flushes any pending events and releases resources. + Close() error +} diff --git a/internal/ledger/noopledger/provider.go b/internal/ledger/noopledger/provider.go new file mode 100644 index 0000000..cd80869 --- /dev/null +++ b/internal/ledger/noopledger/provider.go @@ -0,0 +1,18 @@ +package noopledger + +import ( + "context" + + "github.com/signoz/foundry/internal/ledger" +) + +// provider is a no-op implementation of ledger.Ledger. +type provider struct{} + +// New creates a ledger that does nothing. +func New() ledger.Ledger { + return &provider{} +} + +func (p *provider) Track(_ context.Context, _ map[string]any) {} +func (p *provider) Close() error { return nil } diff --git a/internal/ledger/segmentledger/provider.go b/internal/ledger/segmentledger/provider.go new file mode 100644 index 0000000..5235145 --- /dev/null +++ b/internal/ledger/segmentledger/provider.go @@ -0,0 +1,69 @@ +package segmentledger + +import ( + "context" + "os" + "runtime" + + segment "github.com/segmentio/analytics-go/v3" + "github.com/signoz/foundry/internal/ledger" + "github.com/signoz/foundry/internal/ledger/noopledger" + "github.com/signoz/foundry/internal/version" +) + +// provider implements ledger.Ledger using Segment. +type provider struct { + client segment.Client +} + +// New creates a new Segment ledger provider. +// Returns a noop provider if the write key is not set. +func New(config ledger.Config) ledger.Ledger { + if config.Segment.Key == "" || config.Segment.Key == "" { + return noopledger.New() + } + + client, err := segment.NewWithConfig(config.Segment.Key, segment.Config{}) + if err != nil { + return noopledger.New() + } + + return &provider{ + client: client, + } +} + +func (p *provider) Track(_ context.Context, properties map[string]any) { + if properties == nil { + properties = make(map[string]any) + } + + properties["os"] = runtime.GOOS + properties["arch"] = runtime.GOARCH + properties["foundry_version"] = version.Info.Version() + + props := segment.NewProperties() + for k, v := range properties { + props.Set(k, v) + } + + _ = p.client.Enqueue(segment.Track{ + AnonymousId: getDistinctID(), + Event: "foundryctl", + Properties: props, + }) +} + +func (p *provider) Close() error { + return p.client.Close() +} + +// getDistinctID returns a stable anonymous identifier for the machine. +// It uses the hostname so there is no PII stored. +func getDistinctID() string { + hostname, err := os.Hostname() + if err != nil { + return "unknown" + } + return hostname +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..5821b47 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,74 @@ +package version + +import ( + "fmt" + "os" + "runtime" + "time" +) + +// These will be set via ldflags at build time. +var ( + version string = "" + hash string = "" +) + +var ( + Info Build = Build{ + version: version, + hash: hash, + goVersion: runtime.Version(), + } +) + +// Build contains information about the build environment. +type Build struct { + // The version of the current build. + version string + + // The git hash of the current build. + hash string + + // The version of go. + goVersion string +} + +func (b Build) Version() string { + return b.version +} + +func (b Build) Hash() string { + return b.hash +} + +func (b Build) GoVersion() string { + return b.goVersion +} + +func (b Build) Short() string { + return fmt.Sprintf("foundryctl %s (%s) %s", b.version, b.hash, b.goVersion) +} + +func (b Build) PrettyPrint() { + year := time.Now().Year() + ascii := []string{ + "███████╗ ██████╗ ██╗ ██╗███╗ ██╗██████╗ ██████╗ ██╗ ██╗", + "██╔════╝██╔═══██╗██║ ██║████╗ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝", + "█████╗ ██║ ██║██║ ██║██╔██╗ ██║██║ ██║██████╔╝ ╚████╔╝ ", + "██╔══╝ ██║ ██║██║ ██║██║╚██╗██║██║ ██║██╔══██╗ ╚██╔╝ ", + "██║ ╚██████╔╝╚██████╔╝██║ ╚████║██████╔╝██║ ██║ ██║ ", + "╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ ", + } + + _, _ = fmt.Fprintln(os.Stdout) + for _, line := range ascii { + _, _ = fmt.Fprintln(os.Stdout, line) + } + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, " Version: %s\n", b.version) + _, _ = fmt.Fprintf(os.Stdout, " Commit: %s\n", b.hash) + _, _ = fmt.Fprintf(os.Stdout, " Go: %s\n", b.goVersion) + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, " Copyright %d SigNoz, All rights reserved.\n", year) + _, _ = fmt.Fprintln(os.Stdout) +}