chore: add metrics and error tracking

Andrey Nering created

Change summary

README.md                                            |  34 ++++
go.mod                                               |   3 
go.sum                                               |   6 
internal/cmd/root.go                                 |  25 +++
internal/config/config.go                            |   1 
internal/event/all.go                                |  59 +++++++
internal/event/event.go                              | 114 ++++++++++++++
internal/event/logger.go                             |  27 +++
internal/llm/agent/agent.go                          |  40 ++-
internal/llm/agent/errors.go                         |  15 +
internal/llm/agent/event.go                          |  53 ++++++
internal/log/log.go                                  |   3 
internal/session/session.go                          |   3 
internal/tui/components/dialogs/sessions/sessions.go |   2 
internal/tui/tui.go                                  |   4 
main.go                                              |   2 
schema.json                                          |   5 
17 files changed, 379 insertions(+), 17 deletions(-)

Detailed changes

README.md πŸ”—

@@ -545,7 +545,7 @@ config:
 }
 ```
 
-## Disabling Provider Auto-Updates
+## Provider Auto-Updates
 
 By default, Crush automatically checks for the latest and greatest list of
 providers and models from [Catwalk](https://github.com/charmbracelet/catwalk),
@@ -553,6 +553,8 @@ the open source Crush provider database. This means that when new providers and
 models are available, or when model metadata changes, Crush automatically
 updates your local configuration.
 
+### Disabling automatic provider updates
+
 For those with restricted internet access, or those who prefer to work in
 air-gapped environments, this might not be want you want, and this feature can
 be disabled.
@@ -597,6 +599,36 @@ crush update-providers embedded
 crush update-providers --help
 ```
 
+## Metrics
+
+Crush records pseudonymous usage metrics (tied to a device-specific hash),
+which maintainers rely on to inform development and support priorities. The
+metrics include solely usage metadata; prompts and responses are NEVER
+collected.
+
+Details on exactly what’s collected are in the source code ([here](https://github.com/charmbracelet/crush/tree/main/internal/event)
+and [here](https://github.com/charmbracelet/crush/blob/main/internal/llm/agent/event.go)).
+
+You can opt out of metrics collection at any time by setting the environment
+variable by setting the following in your environment:
+
+```bash
+export CRUSH_DISABLE_METRICS=1
+```
+
+Or by setting the following in your config:
+
+```json
+{
+  "options": {
+    "disable_metrics": true
+  }
+}
+```
+
+Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
+convention which can be enabled via `export DO_NOT_TRACK=1`.
+
 ## A Note on Claude Max and GitHub Copilot
 
 Crush only supports model providers through official, compliant APIs. We do not

go.mod πŸ”—

@@ -81,6 +81,7 @@ require (
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
@@ -95,6 +96,7 @@ require (
 	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/gorilla/websocket v1.5.3 // indirect
+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/klauspost/compress v1.18.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -114,6 +116,7 @@ require (
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/posthog/posthog-go v1.6.10
 	github.com/rivo/uniseg v0.4.7
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect

go.sum πŸ”—

@@ -118,6 +118,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
+github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
 github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
 github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
@@ -162,6 +164,8 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A
 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -233,6 +237,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
+github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
 github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
 github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
 github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=

internal/cmd/root.go πŸ”—

@@ -7,11 +7,13 @@ import (
 	"log/slog"
 	"os"
 	"path/filepath"
+	"strconv"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/tui"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/fang"
@@ -66,6 +68,8 @@ crush -y
 		}
 		defer app.Shutdown()
 
+		event.AppInitialized()
+
 		// Set up the TUI.
 		program := tea.NewProgram(
 			tui.New(app),
@@ -78,11 +82,15 @@ crush -y
 		go app.Subscribe(program)
 
 		if _, err := program.Run(); err != nil {
+			event.Error(err)
 			slog.Error("TUI run error", "error", err)
 			return fmt.Errorf("TUI error: %v", err)
 		}
 		return nil
 	},
+	PostRun: func(cmd *cobra.Command, args []string) {
+		event.AppExited()
+	},
 }
 
 func Execute() {
@@ -135,9 +143,26 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 		return nil, err
 	}
 
+	if shouldEnableMetrics() {
+		event.Init()
+	}
+
 	return appInstance, nil
 }
 
+func shouldEnableMetrics() bool {
+	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
+		return false
+	}
+	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
+		return false
+	}
+	if config.Get().Options.DisableMetrics {
+		return false
+	}
+	return true
+}
+
 func MaybePrependStdin(prompt string) (string, error) {
 	if term.IsTerminal(os.Stdin.Fd()) {
 		return prompt, nil

internal/config/config.go πŸ”—

@@ -153,6 +153,7 @@ type Options struct {
 	DisabledTools             []string     `json:"disabled_tools" jsonschema:"description=Tools to disable"`
 	DisableProviderAutoUpdate bool         `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
 	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
+	DisableMetrics            bool         `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
 }
 
 type MCPs map[string]MCPConfig

internal/event/all.go πŸ”—

@@ -0,0 +1,59 @@
+package event
+
+import (
+	"time"
+)
+
+var appStartTime time.Time
+
+func AppInitialized() {
+	appStartTime = time.Now()
+	send("app initialized")
+}
+
+func AppExited() {
+	duration := time.Since(appStartTime).Truncate(time.Second)
+	send(
+		"app exited",
+		"app duration pretty", duration.String(),
+		"app duration in seconds", int64(duration.Seconds()),
+	)
+	Flush()
+}
+
+func SessionCreated() {
+	send("session created")
+}
+
+func SessionDeleted() {
+	send("session deleted")
+}
+
+func SessionSwitched() {
+	send("session switched")
+}
+
+func FilePickerOpened() {
+	send("filepicker opened")
+}
+
+func PromptSent(props ...any) {
+	send(
+		"prompt sent",
+		props...,
+	)
+}
+
+func PromptResponded(props ...any) {
+	send(
+		"prompt responded",
+		props...,
+	)
+}
+
+func TokensUsed(props ...any) {
+	send(
+		"tokens used",
+		props...,
+	)
+}

internal/event/event.go πŸ”—

@@ -0,0 +1,114 @@
+package event
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+	"reflect"
+	"runtime"
+
+	"github.com/charmbracelet/crush/internal/version"
+	"github.com/denisbrodbeck/machineid"
+	"github.com/posthog/posthog-go"
+)
+
+const (
+	endpoint = "https://data.charm.land"
+	key      = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
+)
+
+var (
+	client posthog.Client
+
+	baseProps = posthog.NewProperties().
+			Set("GOOS", runtime.GOOS).
+			Set("GOARCH", runtime.GOARCH).
+			Set("TERM", os.Getenv("TERM")).
+			Set("SHELL", os.Getenv("SHELL")).
+			Set("Version", version.Version).
+			Set("GoVersion", runtime.Version())
+)
+
+func Init() {
+	c, err := posthog.NewWithConfig(key, posthog.Config{
+		Endpoint: endpoint,
+		Logger:   logger{},
+	})
+	if err != nil {
+		slog.Error("Failed to initialize PostHog client", "error", err)
+	}
+	client = c
+}
+
+// send logs an event to PostHog with the given event name and properties.
+func send(event string, props ...any) {
+	if client == nil {
+		return
+	}
+	err := client.Enqueue(posthog.Capture{
+		DistinctId: distinctId(),
+		Event:      event,
+		Properties: pairsToProps(props...).Merge(baseProps),
+	})
+	if err != nil {
+		slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err)
+		return
+	}
+}
+
+// Error logs an error event to PostHog with the error type and message.
+func Error(err any, props ...any) {
+	if client == nil {
+		return
+	}
+	// The PostHog Go client does not yet support sending exceptions.
+	// We're mimicking the behavior by sending the minimal info required
+	// for PostHog to recognize this as an exception event.
+	props = append(
+		[]any{
+			"$exception_list",
+			[]map[string]string{
+				{"type": reflect.TypeOf(err).String(), "value": fmt.Sprintf("%v", err)},
+			},
+		},
+		props...,
+	)
+	send("$exception", props...)
+}
+
+func Flush() {
+	if client == nil {
+		return
+	}
+	if err := client.Close(); err != nil {
+		slog.Error("Failed to flush PostHog events", "error", err)
+	}
+}
+
+func pairsToProps(props ...any) posthog.Properties {
+	p := posthog.NewProperties()
+
+	if !isEven(len(props)) {
+		slog.Error("Event properties must be provided as key-value pairs", "props", props)
+		return p
+	}
+
+	for i := 0; i < len(props); i += 2 {
+		key := props[i].(string)
+		value := props[i+1]
+		p = p.Set(key, value)
+	}
+	return p
+}
+
+func isEven(n int) bool {
+	return n%2 == 0
+}
+
+func distinctId() string {
+	id, err := machineid.ProtectedID("charm")
+	if err != nil {
+		return "crush-cli"
+	}
+	return id
+}

internal/event/logger.go πŸ”—

@@ -0,0 +1,27 @@
+package event
+
+import (
+	"log/slog"
+
+	"github.com/posthog/posthog-go"
+)
+
+var _ posthog.Logger = logger{}
+
+type logger struct{}
+
+func (logger) Debugf(format string, args ...any) {
+	slog.Debug(format, args...)
+}
+
+func (logger) Logf(format string, args ...any) {
+	slog.Info(format, args...)
+}
+
+func (logger) Warnf(format string, args ...any) {
+	slog.Warn(format, args...)
+}
+
+func (logger) Errorf(format string, args ...any) {
+	slog.Error(format, args...)
+}

internal/llm/agent/agent.go πŸ”—

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/llm/provider"
@@ -25,12 +26,6 @@ import (
 	"github.com/charmbracelet/crush/internal/shell"
 )
 
-// Common errors
-var (
-	ErrRequestCancelled = errors.New("request canceled by user")
-	ErrSessionBusy      = errors.New("session is currently processing another request")
-)
-
 type AgentEventType string
 
 const (
@@ -66,10 +61,11 @@ type Service interface {
 
 type agent struct {
 	*pubsub.Broker[AgentEvent]
-	agentCfg config.Agent
-	sessions session.Service
-	messages message.Service
-	mcpTools []McpTool
+	agentCfg    config.Agent
+	sessions    session.Service
+	messages    message.Service
+	permissions permission.Service
+	mcpTools    []McpTool
 
 	tools *csync.LazySlice[tools.BaseTool]
 	// We need this to be able to update it when model changes
@@ -237,6 +233,7 @@ func NewAgent(
 		activeRequests:      csync.NewMap[string, context.CancelFunc](),
 		tools:               csync.NewLazySlice(toolFn),
 		promptQueue:         csync.NewMap[string, []string](),
+		permissions:         permissions,
 	}, nil
 }
 
@@ -365,8 +362,9 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
 	}
 
 	genCtx, cancel := context.WithCancel(ctx)
-
 	a.activeRequests.Set(sessionID, cancel)
+	startTime := time.Now()
+
 	go func() {
 		slog.Debug("Request started", "sessionID", sessionID)
 		defer log.RecoverPanic("agent.Run", func() {
@@ -377,16 +375,24 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
 			attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
 		}
 		result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
-		if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
-			slog.Error(result.Error.Error())
+		if result.Error != nil {
+			if isCancelledErr(result.Error) {
+				slog.Error("Request canceled", "sessionID", sessionID)
+			} else {
+				slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error())
+				event.Error(result.Error)
+			}
+		} else {
+			slog.Debug("Request completed", "sessionID", sessionID)
 		}
-		slog.Debug("Request completed", "sessionID", sessionID)
+		a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
 		a.activeRequests.Del(sessionID)
 		cancel()
 		a.Publish(pubsub.CreatedEvent, result)
 		events <- result
 		close(events)
 	}()
+	a.eventPromptSent(sessionID)
 	return events, nil
 }
 
@@ -726,13 +732,13 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
 		if err := a.messages.Update(ctx, *assistantMsg); err != nil {
 			return fmt.Errorf("failed to update message: %w", err)
 		}
-		return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
+		return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
 	}
 
 	return nil
 }
 
-func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
+func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
 	sess, err := a.sessions.Get(ctx, sessionID)
 	if err != nil {
 		return fmt.Errorf("failed to get session: %w", err)
@@ -743,6 +749,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.
 		model.CostPer1MIn/1e6*float64(usage.InputTokens) +
 		model.CostPer1MOut/1e6*float64(usage.OutputTokens)
 
+	a.eventTokensUsed(sessionID, usage, cost)
+
 	sess.Cost += cost
 	sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
 	sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens

internal/llm/agent/errors.go πŸ”—

@@ -0,0 +1,15 @@
+package agent
+
+import (
+	"context"
+	"errors"
+)
+
+var (
+	ErrRequestCancelled = errors.New("request canceled by user")
+	ErrSessionBusy      = errors.New("session is currently processing another request")
+)
+
+func isCancelledErr(err error) bool {
+	return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled)
+}

internal/llm/agent/event.go πŸ”—

@@ -0,0 +1,53 @@
+package agent
+
+import (
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/llm/provider"
+)
+
+func (a *agent) eventPromptSent(sessionID string) {
+	event.PromptSent(
+		a.eventCommon(sessionID)...,
+	)
+}
+
+func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) {
+	event.PromptResponded(
+		append(
+			a.eventCommon(sessionID),
+			"prompt duration pretty", duration.String(),
+			"prompt duration in seconds", int64(duration.Seconds()),
+		)...,
+	)
+}
+
+func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) {
+	event.TokensUsed(
+		append(
+			a.eventCommon(sessionID),
+			"input tokens", usage.InputTokens,
+			"output tokens", usage.OutputTokens,
+			"cache read tokens", usage.CacheReadTokens,
+			"cache creation tokens", usage.CacheCreationTokens,
+			"total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
+			"cost", cost,
+		)...,
+	)
+}
+
+func (a *agent) eventCommon(sessionID string) []any {
+	cfg := config.Get()
+	currentModel := cfg.Models[cfg.Agents["coder"].Model]
+
+	return []any{
+		"session id", sessionID,
+		"provider", currentModel.Provider,
+		"model", currentModel.Model,
+		"reasoning effort", currentModel.ReasoningEffort,
+		"thinking mode", currentModel.Think,
+		"yolo mode", a.permissions.SkipRequests(),
+	}
+}

internal/log/log.go πŸ”—

@@ -9,6 +9,7 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/event"
 	"gopkg.in/natefinch/lumberjack.v2"
 )
 
@@ -48,6 +49,8 @@ func Initialized() bool {
 
 func RecoverPanic(name string, cleanup func()) {
 	if r := recover(); r != nil {
+		event.Error(r, "panic", true, "name", name)
+
 		// Create a timestamped panic log file
 		timestamp := time.Now().Format("20060102-150405")
 		filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)

internal/session/session.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"database/sql"
 
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/google/uuid"
 )
@@ -48,6 +49,7 @@ func (s *service) Create(ctx context.Context, title string) (Session, error) {
 	}
 	session := s.fromDBItem(dbSession)
 	s.Publish(pubsub.CreatedEvent, session)
+	event.SessionCreated()
 	return session, nil
 }
 
@@ -89,6 +91,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
 		return err
 	}
 	s.Publish(pubsub.DeletedEvent, session)
+	event.SessionDeleted()
 	return nil
 }
 

internal/tui/components/dialogs/sessions/sessions.go πŸ”—

@@ -4,6 +4,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -99,6 +100,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			selectedItem := s.sessionsList.SelectedItem()
 			if selectedItem != nil {
 				selected := *selectedItem
+				event.SessionSwitched()
 				return s, tea.Sequence(
 					util.CmdHandler(dialogs.CloseDialogMsg{}),
 					util.CmdHandler(

internal/tui/tui.go πŸ”—

@@ -10,6 +10,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
@@ -196,6 +197,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if a.app.CoderAgent.IsBusy() {
 			return a, util.ReportWarn("Agent is busy, please wait...")
 		}
+
 		config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
 
 		// Update the agent with the new model/provider configuration
@@ -211,6 +213,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	// File Picker
 	case commands.OpenFilePickerMsg:
+		event.FilePickerOpened()
+
 		if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
 			// If the commands dialog is already open, close it
 			return a, util.CmdHandler(dialogs.CloseDialogMsg{})

main.go πŸ”—

@@ -10,11 +10,13 @@ import (
 	_ "github.com/joho/godotenv/autoload" // automatically load .env files
 
 	"github.com/charmbracelet/crush/internal/cmd"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/log"
 )
 
 func main() {
 	defer log.RecoverPanic("main", func() {
+		event.Flush()
 		slog.Error("Application terminated due to unhandled panic")
 	})
 

schema.json πŸ”—

@@ -320,6 +320,11 @@
         "attribution": {
           "$ref": "#/$defs/Attribution",
           "description": "Attribution settings for generated content"
+        },
+        "disable_metrics": {
+          "type": "boolean",
+          "description": "Disable sending metrics",
+          "default": false
         }
       },
       "additionalProperties": false,