on main help sign in
chore: add metrics and error tracking
commitef6a32453abdf3b943ef9f56800a7ec9c0135c46 authorAndrey Nering <andreynering@users.noreply.github.com> dateTue 23 Sep 2025, 11:23 (1 year ago) parent9654218
17 files changed+379−17
README.md+33−1 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+24−16 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
README.md+33−1
@@ -545,7 +545,7 @@ config:
545545}
546546```
547547
548−## Disabling Provider Auto-Updates
548+## Provider Auto-Updates
549549
550550By default, Crush automatically checks for the latest and greatest list of
551551providers 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
553553models are available, or when model metadata changes, Crush automatically
554554updates your local configuration.
555555
556+### Disabling automatic provider updates
557+
556558For those with restricted internet access, or those who prefer to work in
557559air-gapped environments, this might not be want you want, and this feature can
558560be disabled.
@@ -597,6 +599,36 @@ crush update-providers embedded
597599crush update-providers --help
598600```
599601
602+## Metrics
603+
604+Crush records pseudonymous usage metrics (tied to a device-specific hash),
605+which maintainers rely on to inform development and support priorities. The
606+metrics include solely usage metadata; prompts and responses are NEVER
607+collected.
608+
609+Details on exactly what’s collected are in the source code ([here](https://github.com/charmbracelet/crush/tree/main/internal/event)
610+and [here](https://github.com/charmbracelet/crush/blob/main/internal/llm/agent/event.go)).
611+
612+You can opt out of metrics collection at any time by setting the environment
613+variable by setting the following in your environment:
614+
615+```bash
616+export CRUSH_DISABLE_METRICS=1
617+```
618+
619+Or by setting the following in your config:
620+
621+```json
622+{
623+ "options": {
624+ "disable_metrics": true
625+ }
626+}
627+```
628+
629+Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
630+convention which can be enabled via `export DO_NOT_TRACK=1`.
631+
600632## A Note on Claude Max and GitHub Copilot
601633
602634Crush only supports model providers through official, compliant APIs. We do not
go.mod+3
@@ -81,6 +81,7 @@ require (
8181 github.com/charmbracelet/x/termios v0.1.1 // indirect
8282 github.com/charmbracelet/x/windows v0.2.2 // indirect
8383 github.com/davecgh/go-spew v1.1.1 // indirect
84+ github.com/denisbrodbeck/machineid v1.0.1
8485 github.com/disintegration/gift v1.1.2 // indirect
8586 github.com/dlclark/regexp2 v1.11.5 // indirect
8687 github.com/dustin/go-humanize v1.0.1 // indirect
@@ -95,6 +96,7 @@ require (
9596 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
9697 github.com/gorilla/css v1.0.1 // indirect
9798 github.com/gorilla/websocket v1.5.3 // indirect
99+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
98100 github.com/inconshreveable/mousetrap v1.1.0 // indirect
99101 github.com/klauspost/compress v1.18.0 // indirect
100102 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -114,6 +116,7 @@ require (
114116 github.com/ncruces/julianday v1.0.0 // indirect
115117 github.com/pierrec/lz4/v4 v4.1.22 // indirect
116118 github.com/pmezard/go-difflib v1.0.0 // indirect
119+ github.com/posthog/posthog-go v1.6.10
117120 github.com/rivo/uniseg v0.4.7
118121 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
119122 github.com/sethvargo/go-retry v0.3.0 // indirect
go.sum+6
@@ -118,6 +118,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
118118github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
119119github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
120120github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
121+github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
122+github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
121123github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
122124github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
123125github.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
162164github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
163165github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
164166github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
167+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
168+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
165169github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
166170github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
167171github.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
233237github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
234238github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
235239github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
240+github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
241+github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
236242github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
237243github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
238244github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
internal/cmd/root.go+25
@@ -7,11 +7,13 @@ import (
77 "log/slog"
88 "os"
99 "path/filepath"
10+ "strconv"
1011
1112 tea "github.com/charmbracelet/bubbletea/v2"
1213 "github.com/charmbracelet/crush/internal/app"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/db"
16+ "github.com/charmbracelet/crush/internal/event"
1517 "github.com/charmbracelet/crush/internal/tui"
1618 "github.com/charmbracelet/crush/internal/version"
1719 "github.com/charmbracelet/fang"
@@ -66,6 +68,8 @@ crush -y
6668 }
6769 defer app.Shutdown()
6870
71+ event.AppInitialized()
72+
6973 // Set up the TUI.
7074 program := tea.NewProgram(
7175 tui.New(app),
@@ -78,11 +82,15 @@ crush -y
7882 go app.Subscribe(program)
7983
8084 if _, err := program.Run(); err != nil {
85+ event.Error(err)
8186 slog.Error("TUI run error", "error", err)
8287 return fmt.Errorf("TUI error: %v", err)
8388 }
8489 return nil
8590 },
91+ PostRun: func(cmd *cobra.Command, args []string) {
92+ event.AppExited()
93+ },
8694}
8795
8896func Execute() {
@@ -135,9 +143,26 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
135143 return nil, err
136144 }
137145
146+ if shouldEnableMetrics() {
147+ event.Init()
148+ }
149+
138150 return appInstance, nil
139151}
140152
153+func shouldEnableMetrics() bool {
154+ if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
155+ return false
156+ }
157+ if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
158+ return false
159+ }
160+ if config.Get().Options.DisableMetrics {
161+ return false
162+ }
163+ return true
164+}
165+
141166func MaybePrependStdin(prompt string) (string, error) {
142167 if term.IsTerminal(os.Stdin.Fd()) {
143168 return prompt, nil
internal/config/config.go+1
@@ -153,6 +153,7 @@ type Options struct {
153153 DisabledTools []string `json:"disabled_tools" jsonschema:"description=Tools to disable"`
154154 DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
155155 Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
156+ DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
156157}
157158
158159type MCPs map[string]MCPConfig
internal/event/all.gonew file+59
@@ -0,0 +1,59 @@
1+package event
2+
3+import (
4+ "time"
5+)
6+
7+var appStartTime time.Time
8+
9+func AppInitialized() {
10+ appStartTime = time.Now()
11+ send("app initialized")
12+}
13+
14+func AppExited() {
15+ duration := time.Since(appStartTime).Truncate(time.Second)
16+ send(
17+ "app exited",
18+ "app duration pretty", duration.String(),
19+ "app duration in seconds", int64(duration.Seconds()),
20+ )
21+ Flush()
22+}
23+
24+func SessionCreated() {
25+ send("session created")
26+}
27+
28+func SessionDeleted() {
29+ send("session deleted")
30+}
31+
32+func SessionSwitched() {
33+ send("session switched")
34+}
35+
36+func FilePickerOpened() {
37+ send("filepicker opened")
38+}
39+
40+func PromptSent(props ...any) {
41+ send(
42+ "prompt sent",
43+ props...,
44+ )
45+}
46+
47+func PromptResponded(props ...any) {
48+ send(
49+ "prompt responded",
50+ props...,
51+ )
52+}
53+
54+func TokensUsed(props ...any) {
55+ send(
56+ "tokens used",
57+ props...,
58+ )
59+}
internal/event/event.gonew file+114
@@ -0,0 +1,114 @@
1+package event
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "os"
7+ "reflect"
8+ "runtime"
9+
10+ "github.com/charmbracelet/crush/internal/version"
11+ "github.com/denisbrodbeck/machineid"
12+ "github.com/posthog/posthog-go"
13+)
14+
15+const (
16+ endpoint = "https://data.charm.land"
17+ key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
18+)
19+
20+var (
21+ client posthog.Client
22+
23+ baseProps = posthog.NewProperties().
24+ Set("GOOS", runtime.GOOS).
25+ Set("GOARCH", runtime.GOARCH).
26+ Set("TERM", os.Getenv("TERM")).
27+ Set("SHELL", os.Getenv("SHELL")).
28+ Set("Version", version.Version).
29+ Set("GoVersion", runtime.Version())
30+)
31+
32+func Init() {
33+ c, err := posthog.NewWithConfig(key, posthog.Config{
34+ Endpoint: endpoint,
35+ Logger: logger{},
36+ })
37+ if err != nil {
38+ slog.Error("Failed to initialize PostHog client", "error", err)
39+ }
40+ client = c
41+}
42+
43+// send logs an event to PostHog with the given event name and properties.
44+func send(event string, props ...any) {
45+ if client == nil {
46+ return
47+ }
48+ err := client.Enqueue(posthog.Capture{
49+ DistinctId: distinctId(),
50+ Event: event,
51+ Properties: pairsToProps(props...).Merge(baseProps),
52+ })
53+ if err != nil {
54+ slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err)
55+ return
56+ }
57+}
58+
59+// Error logs an error event to PostHog with the error type and message.
60+func Error(err any, props ...any) {
61+ if client == nil {
62+ return
63+ }
64+ // The PostHog Go client does not yet support sending exceptions.
65+ // We're mimicking the behavior by sending the minimal info required
66+ // for PostHog to recognize this as an exception event.
67+ props = append(
68+ []any{
69+ "$exception_list",
70+ []map[string]string{
71+ {"type": reflect.TypeOf(err).String(), "value": fmt.Sprintf("%v", err)},
72+ },
73+ },
74+ props...,
75+ )
76+ send("$exception", props...)
77+}
78+
79+func Flush() {
80+ if client == nil {
81+ return
82+ }
83+ if err := client.Close(); err != nil {
84+ slog.Error("Failed to flush PostHog events", "error", err)
85+ }
86+}
87+
88+func pairsToProps(props ...any) posthog.Properties {
89+ p := posthog.NewProperties()
90+
91+ if !isEven(len(props)) {
92+ slog.Error("Event properties must be provided as key-value pairs", "props", props)
93+ return p
94+ }
95+
96+ for i := 0; i < len(props); i += 2 {
97+ key := props[i].(string)
98+ value := props[i+1]
99+ p = p.Set(key, value)
100+ }
101+ return p
102+}
103+
104+func isEven(n int) bool {
105+ return n%2 == 0
106+}
107+
108+func distinctId() string {
109+ id, err := machineid.ProtectedID("charm")
110+ if err != nil {
111+ return "crush-cli"
112+ }
113+ return id
114+}
internal/event/logger.gonew file+27
@@ -0,0 +1,27 @@
1+package event
2+
3+import (
4+ "log/slog"
5+
6+ "github.com/posthog/posthog-go"
7+)
8+
9+var _ posthog.Logger = logger{}
10+
11+type logger struct{}
12+
13+func (logger) Debugf(format string, args ...any) {
14+ slog.Debug(format, args...)
15+}
16+
17+func (logger) Logf(format string, args ...any) {
18+ slog.Info(format, args...)
19+}
20+
21+func (logger) Warnf(format string, args ...any) {
22+ slog.Warn(format, args...)
23+}
24+
25+func (logger) Errorf(format string, args ...any) {
26+ slog.Error(format, args...)
27+}
internal/llm/agent/agent.go+24−16
@@ -12,6 +12,7 @@ import (
1212 "github.com/charmbracelet/catwalk/pkg/catwalk"
1313 "github.com/charmbracelet/crush/internal/config"
1414 "github.com/charmbracelet/crush/internal/csync"
15+ "github.com/charmbracelet/crush/internal/event"
1516 "github.com/charmbracelet/crush/internal/history"
1617 "github.com/charmbracelet/crush/internal/llm/prompt"
1718 "github.com/charmbracelet/crush/internal/llm/provider"
@@ -25,12 +26,6 @@ import (
2526 "github.com/charmbracelet/crush/internal/shell"
2627)
2728
28−// Common errors
29−var (
30− ErrRequestCancelled = errors.New("request canceled by user")
31− ErrSessionBusy = errors.New("session is currently processing another request")
32−)
33−
3429type AgentEventType string
3530
3631const (
@@ -66,10 +61,11 @@ type Service interface {
6661
6762type agent struct {
6863 *pubsub.Broker[AgentEvent]
69− agentCfg config.Agent
70− sessions session.Service
71− messages message.Service
72− mcpTools []McpTool
64+ agentCfg config.Agent
65+ sessions session.Service
66+ messages message.Service
67+ permissions permission.Service
68+ mcpTools []McpTool
7369
7470 tools *csync.LazySlice[tools.BaseTool]
7571 // We need this to be able to update it when model changes
@@ -237,6 +233,7 @@ func NewAgent(
237233 activeRequests: csync.NewMap[string, context.CancelFunc](),
238234 tools: csync.NewLazySlice(toolFn),
239235 promptQueue: csync.NewMap[string, []string](),
236+ permissions: permissions,
240237 }, nil
241238}
242239
@@ -365,8 +362,9 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
365362 }
366363
367364 genCtx, cancel := context.WithCancel(ctx)
368−
369365 a.activeRequests.Set(sessionID, cancel)
366+ startTime := time.Now()
367+
370368 go func() {
371369 slog.Debug("Request started", "sessionID", sessionID)
372370 defer log.RecoverPanic("agent.Run", func() {
@@ -377,16 +375,24 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
377375 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
378376 }
379377 result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
380− if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
381− slog.Error(result.Error.Error())
378+ if result.Error != nil {
379+ if isCancelledErr(result.Error) {
380+ slog.Error("Request canceled", "sessionID", sessionID)
381+ } else {
382+ slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error())
383+ event.Error(result.Error)
384+ }
385+ } else {
386+ slog.Debug("Request completed", "sessionID", sessionID)
382387 }
383− slog.Debug("Request completed", "sessionID", sessionID)
388+ a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
384389 a.activeRequests.Del(sessionID)
385390 cancel()
386391 a.Publish(pubsub.CreatedEvent, result)
387392 events <- result
388393 close(events)
389394 }()
395+ a.eventPromptSent(sessionID)
390396 return events, nil
391397}
392398
@@ -726,13 +732,13 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
726732 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
727733 return fmt.Errorf("failed to update message: %w", err)
728734 }
729− return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
735+ return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
730736 }
731737
732738 return nil
733739}
734740
735−func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
741+func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
736742 sess, err := a.sessions.Get(ctx, sessionID)
737743 if err != nil {
738744 return fmt.Errorf("failed to get session: %w", err)
@@ -743,6 +749,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.
743749 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
744750 model.CostPer1MOut/1e6*float64(usage.OutputTokens)
745751
752+ a.eventTokensUsed(sessionID, usage, cost)
753+
746754 sess.Cost += cost
747755 sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
748756 sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
internal/llm/agent/errors.gonew file+15
@@ -0,0 +1,15 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+)
7+
8+var (
9+ ErrRequestCancelled = errors.New("request canceled by user")
10+ ErrSessionBusy = errors.New("session is currently processing another request")
11+)
12+
13+func isCancelledErr(err error) bool {
14+ return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled)
15+}
internal/llm/agent/event.gonew file+53
@@ -0,0 +1,53 @@
1+package agent
2+
3+import (
4+ "time"
5+
6+ "github.com/charmbracelet/crush/internal/config"
7+ "github.com/charmbracelet/crush/internal/event"
8+ "github.com/charmbracelet/crush/internal/llm/provider"
9+)
10+
11+func (a *agent) eventPromptSent(sessionID string) {
12+ event.PromptSent(
13+ a.eventCommon(sessionID)...,
14+ )
15+}
16+
17+func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) {
18+ event.PromptResponded(
19+ append(
20+ a.eventCommon(sessionID),
21+ "prompt duration pretty", duration.String(),
22+ "prompt duration in seconds", int64(duration.Seconds()),
23+ )...,
24+ )
25+}
26+
27+func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) {
28+ event.TokensUsed(
29+ append(
30+ a.eventCommon(sessionID),
31+ "input tokens", usage.InputTokens,
32+ "output tokens", usage.OutputTokens,
33+ "cache read tokens", usage.CacheReadTokens,
34+ "cache creation tokens", usage.CacheCreationTokens,
35+ "total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
36+ "cost", cost,
37+ )...,
38+ )
39+}
40+
41+func (a *agent) eventCommon(sessionID string) []any {
42+ cfg := config.Get()
43+ currentModel := cfg.Models[cfg.Agents["coder"].Model]
44+
45+ return []any{
46+ "session id", sessionID,
47+ "provider", currentModel.Provider,
48+ "model", currentModel.Model,
49+ "reasoning effort", currentModel.ReasoningEffort,
50+ "thinking mode", currentModel.Think,
51+ "yolo mode", a.permissions.SkipRequests(),
52+ }
53+}
internal/log/log.go+3
@@ -9,6 +9,7 @@ import (
99 "sync/atomic"
1010 "time"
1111
12+ "github.com/charmbracelet/crush/internal/event"
1213 "gopkg.in/natefinch/lumberjack.v2"
1314)
1415
@@ -48,6 +49,8 @@ func Initialized() bool {
4849
4950func RecoverPanic(name string, cleanup func()) {
5051 if r := recover(); r != nil {
52+ event.Error(r, "panic", true, "name", name)
53+
5154 // Create a timestamped panic log file
5255 timestamp := time.Now().Format("20060102-150405")
5356 filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)
internal/session/session.go+3
@@ -5,6 +5,7 @@ import (
55 "database/sql"
66
77 "github.com/charmbracelet/crush/internal/db"
8+ "github.com/charmbracelet/crush/internal/event"
89 "github.com/charmbracelet/crush/internal/pubsub"
910 "github.com/google/uuid"
1011)
@@ -48,6 +49,7 @@ func (s *service) Create(ctx context.Context, title string) (Session, error) {
4849 }
4950 session := s.fromDBItem(dbSession)
5051 s.Publish(pubsub.CreatedEvent, session)
52+ event.SessionCreated()
5153 return session, nil
5254}
5355
@@ -89,6 +91,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
8991 return err
9092 }
9193 s.Publish(pubsub.DeletedEvent, session)
94+ event.SessionDeleted()
9295 return nil
9396}
9497
internal/tui/components/dialogs/sessions/sessions.go+2
@@ -4,6 +4,7 @@ import (
44 "github.com/charmbracelet/bubbles/v2/help"
55 "github.com/charmbracelet/bubbles/v2/key"
66 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/event"
78 "github.com/charmbracelet/crush/internal/session"
89 "github.com/charmbracelet/crush/internal/tui/components/chat"
910 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -99,6 +100,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99100 selectedItem := s.sessionsList.SelectedItem()
100101 if selectedItem != nil {
101102 selected := *selectedItem
103+ event.SessionSwitched()
102104 return s, tea.Sequence(
103105 util.CmdHandler(dialogs.CloseDialogMsg{}),
104106 util.CmdHandler(
internal/tui/tui.go+4
@@ -10,6 +10,7 @@ import (
1010 tea "github.com/charmbracelet/bubbletea/v2"
1111 "github.com/charmbracelet/crush/internal/app"
1212 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/event"
1314 "github.com/charmbracelet/crush/internal/llm/agent"
1415 "github.com/charmbracelet/crush/internal/permission"
1516 "github.com/charmbracelet/crush/internal/pubsub"
@@ -196,6 +197,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
196197 if a.app.CoderAgent.IsBusy() {
197198 return a, util.ReportWarn("Agent is busy, please wait...")
198199 }
200+
199201 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
200202
201203 // Update the agent with the new model/provider configuration
@@ -211,6 +213,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
211213
212214 // File Picker
213215 case commands.OpenFilePickerMsg:
216+ event.FilePickerOpened()
217+
214218 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
215219 // If the commands dialog is already open, close it
216220 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
main.go+2
@@ -10,11 +10,13 @@ import (
1010 _ "github.com/joho/godotenv/autoload" // automatically load .env files
1111
1212 "github.com/charmbracelet/crush/internal/cmd"
13+ "github.com/charmbracelet/crush/internal/event"
1314 "github.com/charmbracelet/crush/internal/log"
1415)
1516
1617func main() {
1718 defer log.RecoverPanic("main", func() {
19+ event.Flush()
1820 slog.Error("Application terminated due to unhandled panic")
1921 })
2022
schema.json+5
@@ -320,6 +320,11 @@
320320 "attribution": {
321321 "$ref": "#/$defs/Attribution",
322322 "description": "Attribution settings for generated content"
323+ },
324+ "disable_metrics": {
325+ "type": "boolean",
326+ "description": "Disable sending metrics",
327+ "default": false
323328 }
324329 },
325330 "additionalProperties": false,