diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e75a2fe0..01d39f02 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,7 +15,7 @@ builds: - -trimpath ldflags: - - "-s -w -X main.version={{.Version}}" + - "-s -w -X main.version={{.Version}} -X github.com/CircleCI-Public/chunk-cli/internal/cmd.writeKey={{.Env.SEGMENT_WRITE_KEY}}" goos: - linux diff --git a/go.mod b/go.mod index d529fadc..cbe78007 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,12 @@ require ( github.com/coder/websocket v1.8.14 github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/segmentio/analytics-go/v3 v3.3.0 github.com/sethvargo/go-envconfig v1.3.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.51.0 golang.org/x/term v0.43.0 @@ -59,6 +62,7 @@ require ( github.com/bitfield/gotestdox v0.2.2 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect github.com/bombsimon/wsl/v5 v5.8.0 // indirect github.com/breml/bidichk v0.3.3 // indirect @@ -213,6 +217,7 @@ require ( github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect github.com/securego/gosec/v2 v2.26.1 // indirect + github.com/segmentio/backo-go v1.0.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/sonatard/noctx v0.5.1 // indirect @@ -220,7 +225,6 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.12.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect diff --git a/go.sum b/go.sum index c6cacd26..5fdbd79a 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5 github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +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/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= @@ -369,6 +371,8 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmI github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= @@ -601,6 +605,10 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4= github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE= +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.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= diff --git a/internal/cmd/errmsg.go b/internal/cmd/errmsg.go index 52331db2..1be7e19f 100644 --- a/internal/cmd/errmsg.go +++ b/internal/cmd/errmsg.go @@ -3,6 +3,7 @@ package cmd const ( configFilePermHint = "Check file permissions on the chunk config file." msgCouldNotLoadConfig = "Could not load configuration." + msgCouldNotSaveConfig = "Could not save configuration." msgCouldNotAccessConfig = "Could not access configuration." msgCouldNotDetermineWorkDir = "Could not determine working directory." msgCouldNotLoadSidecar = "Could not load the active sidecar." diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ee9d83d7..395752a1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,12 +1,30 @@ package cmd import ( + "context" + "os" + "runtime" + "slices" + "strings" + "time" + + "github.com/google/uuid" "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/CircleCI-Public/chunk-cli/internal/config" + "github.com/CircleCI-Public/chunk-cli/internal/telemetry" ) +// writeKey is the Segment write key injected at build time via -ldflags. +// Empty by default; telemetry silently uses ModeNOOP when unset. +var writeKey string + func NewRootCmd(version string) *cobra.Command { cobra.EnableTraverseRunHooks = true + telem := &delegatingTelemetry{} + rootCmd := &cobra.Command{ Use: "chunk", Short: "Generate AI review context and trigger AI coding tasks", @@ -14,6 +32,12 @@ func NewRootCmd(version string) *cobra.Command { SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true + noTelemetry, _ := cmd.Flags().GetBool("no-telemetry") + tc, err := newTelemetryClient(cmd.Context(), version, noTelemetry) + if err != nil { + tc, _ = telemetry.New(cmd.Context(), telemetry.Config{Mode: telemetry.ModeNOOP}) + } + telem.Client = tc return nil }, } @@ -39,6 +63,25 @@ Environment Variables: SSH_AUTH_SOCK SSH agent socket for sidecar key auth NO_COLOR Disable colored output CI Disable interactive prompts (set by most CI systems) + CHUNK_NO_TELEMETRY Disable telemetry (also: NO_ANALYTICS, DO_NOT_TRACK) + +Telemetry: + chunk collects anonymous usage statistics to help improve the tool. + + What we collect: + - Which commands are used + - Whether commands succeed or fail (no error messages) + - chunk version, OS, and architecture + + What we do NOT collect: + - Command arguments or flag values + - File or directory names + - IP addresses or hostnames + - Any personally identifiable information + + To disable telemetry: + Set CHUNK_NO_TELEMETRY=1 in your environment + Pass --no-telemetry to disable for a single invocation Configuration: ~/.config/chunk/config.json User credentials and settings ($XDG_CONFIG_HOME/chunk/config.json) @@ -57,11 +100,136 @@ Configuration: rootCmd.AddCommand(newValidateCmd()) rootCmd.AddCommand(newHookCmd()) rootCmd.AddCommand(newUpgradeCmd()) - rootCmd.AddCommand(newCommandsCmd()) + recordTelemetryForSubcommands(rootCmd, telem) + rootCmd.PersistentFlags().Bool("insecure-storage", false, "do not use the system's secure storage for storing tokens") _ = rootCmd.PersistentFlags().MarkHidden("insecure-storage") + rootCmd.PersistentFlags().Bool("no-telemetry", false, "Disable telemetry for this invocation") return rootCmd } + +// delegatingTelemetry wraps a *telemetry.Client that is populated lazily in +// PersistentPreRunE, allowing subcommand RunE wrappers registered at startup +// to call through to the client once it is available. +type delegatingTelemetry struct { + *telemetry.Client +} + +type tracker interface { + Track(eventName string, props map[string]any) error + Close() error +} + +// recordTelemetry wraps cmd.RunE to emit a command_invocation event and flush +// the telemetry client after each command. The 500 ms timeout prevents a slow +// Segment endpoint from stalling the CLI. +func recordTelemetry(cmd *cobra.Command, t tracker) { + if cmd.Annotations["telemetry"] == "disabled" { + return + } + if cmd.RunE == nil { + return + } + original := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runErr := original(cmd, args) + + var flags []string + cmd.Flags().Visit(func(f *pflag.Flag) { + flags = append(flags, f.Name) + }) + slices.Sort(flags) + + _ = t.Track("command_invocation", map[string]any{ + "command": cmd.CommandPath(), + "flags": strings.Join(flags, ","), + "success": runErr == nil, + "action": "invoked", + }) + + done := make(chan struct{}) + go func() { + defer close(done) + _ = t.Close() + }() + select { + case <-done: + case <-time.After(500 * time.Millisecond): + } + + return runErr + } +} + +func recordTelemetryForSubcommands(cmd *cobra.Command, t tracker) { + for _, c := range cmd.Commands() { + recordTelemetry(c, t) + recordTelemetryForSubcommands(c, t) + } +} + +// newTelemetryClient determines the appropriate mode and constructs the client. +func newTelemetryClient(ctx context.Context, version string, noTelemetryFlag bool) (*telemetry.Client, error) { + mode := telemetry.ModeSend + if noTelemetryFlag || telemetryOptedOut() || writeKey == "" { + mode = telemetry.ModeNOOP + } + + cfg, err := config.Load() + if err != nil { + return nil, err + } + if cfg.NoTelemetry { + mode = telemetry.ModeNOOP + } + + env, err := config.LoadEnv(ctx) + if err != nil { + return nil, err + } + + instanceID := cfg.InstanceID + freshID := instanceID == "" + if freshID { + instanceID = uuid.NewString() + cfg.InstanceID = instanceID + _ = config.Save(cfg) + } + + tc, err := telemetry.New(ctx, telemetry.Config{ + Mode: mode, + WriteKey: writeKey, + User: telemetry.User{ + InstanceID: instanceID, + OrganizationID: env.CircleCIOrgID, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Version: version, + }, + }) + if err != nil { + return nil, err + } + if freshID && mode == telemetry.ModeSend { + _ = tc.Identify() + } + return tc, nil +} + +// telemetryOptedOut returns true if any standard opt-out environment variable is set. +func telemetryOptedOut() bool { + for _, env := range []string{ + config.EnvChunkNoTelemetry, + config.EnvNoAnalytics, + config.EnvDoNotTrack, + config.EnvCI, + } { + if os.Getenv(env) != "" { + return true + } + } + return false +} diff --git a/internal/config/config.go b/internal/config/config.go index 48920d08..6b5721a7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,7 @@ const ( EnvModel = "CODE_REVIEW_CLI_MODEL" EnvCircleCIOrgID = "CIRCLECI_ORG_ID" EnvChunkHooksDisabled = "CHUNK_HOOKS_DISABLED" + EnvChunkNoTelemetry = "CHUNK_NO_TELEMETRY" ) // System/standard environment variable names. @@ -66,6 +67,9 @@ const ( EnvXDGConfigHome = "XDG_CONFIG_HOME" EnvXDGStateHome = "XDG_STATE_HOME" EnvXDGDataHome = "XDG_DATA_HOME" + EnvNoAnalytics = "NO_ANALYTICS" + EnvDoNotTrack = "DO_NOT_TRACK" + EnvCI = "CI" ) // EnvVars holds all environment variables the application reads. @@ -105,6 +109,10 @@ type UserConfig struct { CircleCIToken string `json:"circleCIToken,omitempty"` GitHubToken string `json:"gitHubToken,omitempty"` Model string `json:"model,omitempty"` + // InstanceID is a stable UUID generated on first run and used as the + // telemetry device identifier. + InstanceID string `json:"instanceID,omitempty"` + NoTelemetry bool `json:"noTelemetry,omitempty"` // LegacyAPIKey reads the pre-rename "apiKey" field so existing users don't // silently lose their stored Anthropic key on upgrade. Migrated into diff --git a/internal/telemetry/log.go b/internal/telemetry/log.go new file mode 100644 index 00000000..175c04a3 --- /dev/null +++ b/internal/telemetry/log.go @@ -0,0 +1,29 @@ +package telemetry + +import ( + "context" + "fmt" + "os" + + "github.com/segmentio/analytics-go/v3" +) + +type loggingClient struct { + ctx context.Context +} + +func newLoggingClient(ctx context.Context) *loggingClient { + return &loggingClient{ctx: ctx} +} + +func (l *loggingClient) Close() error { return nil } + +func (l *loggingClient) Enqueue(m analytics.Message) error { + switch m := m.(type) { + case analytics.Track: + fmt.Fprintf(os.Stderr, "[telemetry] track %s %v\n", m.Event, m.Properties) + case analytics.Identify: + fmt.Fprintf(os.Stderr, "[telemetry] identify %s\n", m.AnonymousId) + } + return nil +} diff --git a/internal/telemetry/noop.go b/internal/telemetry/noop.go new file mode 100644 index 00000000..3ef26bc7 --- /dev/null +++ b/internal/telemetry/noop.go @@ -0,0 +1,8 @@ +package telemetry + +import "github.com/segmentio/analytics-go/v3" + +type noopClient struct{} + +func (noopClient) Close() error { return nil } +func (noopClient) Enqueue(_ analytics.Message) error { return nil } diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000..0f2dbc7c --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,135 @@ +package telemetry + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/segmentio/analytics-go/v3" +) + +type Client struct { + client analytics.Client + user User +} + +type Mode int + +const ( + // ModeNOOP disables telemetry; all operations are silent no-ops. + ModeNOOP Mode = iota + // ModeSend sends events to Segment. + ModeSend + // ModeLog logs events to stderr instead of sending them. + ModeLog +) + +type Config struct { + Mode Mode + + // WriteKey is the Segment write key, required for ModeSend. + WriteKey string + // Endpoint is the Segment endpoint (optional, defaults to segment.io). + Endpoint string + // BatchSize controls how many events are batched before sending. Only useful for testing. + BatchSize int + + User User +} + +type User struct { + // InstanceID is a stable identifier for this device. A random UUID is generated if empty. + InstanceID string + OrganizationID string + OS string + Arch string + Version string +} + +func (u User) toContext() *analytics.Context { + return &analytics.Context{ + App: analytics.AppInfo{ + Name: "chunk", + Version: u.Version, + }, + OS: analytics.OSInfo{ + Name: u.OS, + }, + Device: analytics.DeviceInfo{ + Id: u.InstanceID, + Manufacturer: "CircleCI Ltd", + Name: "chunk", + }, + Extra: map[string]interface{}{ + "arch": u.Arch, + }, + } +} + +// New creates a telemetry client. The returned client is never nil; +// when telemetry is disabled or unavailable a no-op client is used. +func New(ctx context.Context, cfg Config) (*Client, error) { + var client analytics.Client + switch cfg.Mode { + case ModeNOOP: + client = noopClient{} + case ModeSend: + if cfg.WriteKey == "" { + return nil, fmt.Errorf("write key is required for ModeSend") + } + var err error + client, err = analytics.NewWithConfig(cfg.WriteKey, analytics.Config{ + Endpoint: cfg.Endpoint, + BatchSize: cfg.BatchSize, + }) + if err != nil { + return nil, fmt.Errorf("failed to create segment client: %w", err) + } + case ModeLog: + client = newLoggingClient(ctx) + } + + if cfg.User.InstanceID == "" { + cfg.User.InstanceID = uuid.NewString() + } + + return &Client{ + client: client, + user: cfg.User, + }, nil +} + +func (c *Client) Identify() error { + return c.client.Enqueue(analytics.Identify{ + AnonymousId: c.user.InstanceID, + Context: c.user.toContext(), + Integrations: analytics.NewIntegrations().Enable("Amplitude"), + }) +} + +func (c *Client) Close() error { + return c.client.Close() +} + +// Track sends an analytics event. +func (c *Client) Track(eventName string, props map[string]any) error { + extras := analytics.NewProperties() + extras.Set("sender", "chunk-cli") + extras.Set("team_name", "factory") + if c.user.OrganizationID != "" { + extras.Set("organization_id", c.user.OrganizationID) + } + for key, val := range props { + extras.Set(key, val) + } + + return c.client.Enqueue(analytics.Track{ + Event: eventName, + Timestamp: time.Now(), + Properties: extras, + AnonymousId: c.user.InstanceID, + Context: c.user.toContext(), + Integrations: analytics.NewIntegrations().Enable("Amplitude"), + }) +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000..17bc33fe --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,142 @@ +package telemetry_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" + + "github.com/CircleCI-Public/chunk-cli/internal/telemetry" +) + +const goodWriteKey = "b4b250188e5994cf45e7b0e5" + +func TestClient_Track(t *testing.T) { + instanceID := uuid.NewString() + + fs := newFakeSegment(goodWriteKey) + srv := httptest.NewServer(fs) + t.Cleanup(srv.Close) + + ac, err := telemetry.New(context.Background(), telemetry.Config{ + Mode: telemetry.ModeSend, + Endpoint: srv.URL, + WriteKey: goodWriteKey, + BatchSize: 2, + User: telemetry.User{ + InstanceID: instanceID, + OS: "linux", + Arch: "amd64", + Version: "1.0.0", + }, + }) + assert.NilError(t, err) + + assert.NilError(t, ac.Identify()) + assert.NilError(t, ac.Track("build-prompt", map[string]any{"success": true})) + assert.NilError(t, ac.Close()) + + var allMsgs []batchMessage + for _, b := range fs.Batches() { + allMsgs = append(allMsgs, b.Messages...) + } + assert.Assert(t, len(allMsgs) == 2, "expected 2 messages, got %d", len(allMsgs)) + + var identifyMsg, trackMsg *batchMessage + for i := range allMsgs { + switch allMsgs[i].Type { + case "identify": + identifyMsg = &allMsgs[i] + case "track": + trackMsg = &allMsgs[i] + } + } + + assert.Assert(t, identifyMsg != nil, "expected an identify message") + assert.Equal(t, identifyMsg.AnonymousID, instanceID) + + assert.Assert(t, trackMsg != nil, "expected a track message") + assert.Equal(t, trackMsg.Event, "build-prompt") + assert.Equal(t, trackMsg.AnonymousID, instanceID) + assert.Check(t, cmp.DeepEqual(trackMsg.Properties, map[string]any{ + "success": true, + "sender": "chunk-cli", + "team_name": "factory", + })) +} + +func TestClient_ModeNOOP(t *testing.T) { + ac, err := telemetry.New(context.Background(), telemetry.Config{Mode: telemetry.ModeNOOP}) + assert.NilError(t, err) + assert.Assert(t, ac != nil) + assert.NilError(t, ac.Identify()) + assert.NilError(t, ac.Track("build-prompt", nil)) + assert.NilError(t, ac.Close()) +} + +type batchMessage struct { + Type string `json:"type"` + AnonymousID string `json:"anonymousId"` + Event string `json:"event"` + Properties map[string]any `json:"properties"` +} + +type batch struct { + SentAt time.Time `json:"sentAt"` + Messages []batchMessage `json:"batch"` +} + +func newFakeSegment(apiKey string) *fakeSegment { + fs := &fakeSegment{apiKey: basicAuth(apiKey, "")} + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/batch", fs.handleBatch) + fs.Handler = mux + return fs +} + +type fakeSegment struct { + http.Handler + + apiKey string + batches []batch + mu sync.RWMutex +} + +func (s *fakeSegment) handleBatch(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Basic "+s.apiKey { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var b batch + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + + s.mu.Lock() + s.batches = append(s.batches, b) + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) +} + +func (s *fakeSegment) Batches() []batch { + s.mu.RLock() + defer s.mu.RUnlock() + return slices.Clone(s.batches) +} + +func basicAuth(username, password string) string { + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +}