on main help sign in
chore: add metrics and error tracking
ef6a324Andrey Nering
README.md+33−1
@@ -545,7 +545,7 @@ config:
545}
545}
546```
546```
547
547
548−## Disabling Provider Auto-Updates
548+## Provider Auto-Updates
549
549
550By default, Crush automatically checks for the latest and greatest list of
550By default, Crush automatically checks for the latest and greatest list of
551providers and models from [Catwalk](https://github.com/charmbracelet/catwalk),
551providers 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
553models are available, or when model metadata changes, Crush automatically
553models are available, or when model metadata changes, Crush automatically
554updates your local configuration.
554updates your local configuration.
555
555
556+### Disabling automatic provider updates
557+
556For those with restricted internet access, or those who prefer to work in
558For those with restricted internet access, or those who prefer to work in
557air-gapped environments, this might not be want you want, and this feature can
559air-gapped environments, this might not be want you want, and this feature can
558be disabled.
560be disabled.
@@ -597,6 +599,36 @@ crush update-providers embedded
597crush update-providers --help
599crush update-providers --help
598```
600```
599
601
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+
600## A Note on Claude Max and GitHub Copilot
632## A Note on Claude Max and GitHub Copilot
601
633
602Crush only supports model providers through official, compliant APIs. We do not
634Crush only supports model providers through official, compliant APIs. We do not
go.mod+3
@@ -81,6 +81,7 @@ require (
81 github.com/charmbracelet/x/termios v0.1.1 // indirect
81 github.com/charmbracelet/x/termios v0.1.1 // indirect
82 github.com/charmbracelet/x/windows v0.2.2 // indirect
82 github.com/charmbracelet/x/windows v0.2.2 // indirect
83 github.com/davecgh/go-spew v1.1.1 // indirect
83 github.com/davecgh/go-spew v1.1.1 // indirect
84+ github.com/denisbrodbeck/machineid v1.0.1
84 github.com/disintegration/gift v1.1.2 // indirect
85 github.com/disintegration/gift v1.1.2 // indirect
85 github.com/dlclark/regexp2 v1.11.5 // indirect
86 github.com/dlclark/regexp2 v1.11.5 // indirect
86 github.com/dustin/go-humanize v1.0.1 // indirect
87 github.com/dustin/go-humanize v1.0.1 // indirect
@@ -95,6 +96,7 @@ require (
95 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
96 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
96 github.com/gorilla/css v1.0.1 // indirect
97 github.com/gorilla/css v1.0.1 // indirect
97 github.com/gorilla/websocket v1.5.3 // indirect
98 github.com/gorilla/websocket v1.5.3 // indirect
99+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
98 github.com/inconshreveable/mousetrap v1.1.0 // indirect
100 github.com/inconshreveable/mousetrap v1.1.0 // indirect
99 github.com/klauspost/compress v1.18.0 // indirect
101 github.com/klauspost/compress v1.18.0 // indirect
100 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
102 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -114,6 +116,7 @@ require (
114 github.com/ncruces/julianday v1.0.0 // indirect
116 github.com/ncruces/julianday v1.0.0 // indirect
115 github.com/pierrec/lz4/v4 v4.1.22 // indirect
117 github.com/pierrec/lz4/v4 v4.1.22 // indirect
116 github.com/pmezard/go-difflib v1.0.0 // indirect
118 github.com/pmezard/go-difflib v1.0.0 // indirect
119+ github.com/posthog/posthog-go v1.6.10
117 github.com/rivo/uniseg v0.4.7
120 github.com/rivo/uniseg v0.4.7
118 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
121 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
119 github.com/sethvargo/go-retry v0.3.0 // indirect
122 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
118github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
118github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
119github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
119github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
120github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
120github.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=
121github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
123github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
122github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
124github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
123github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
125github.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
162github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
164github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
163github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
165github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
164github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
166github.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=
165github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
169github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
166github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
170github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
167github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
171github.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
233github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
237github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
234github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
238github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
235github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
239github.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=
236github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
242github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
237github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
243github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
238github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
244github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
internal/cmd/root.go+25
@@ -7,11 +7,13 @@ import (
7 "log/slog"
7 "log/slog"
8 "os"
8 "os"
9 "path/filepath"
9 "path/filepath"
10+ "strconv"
10
11
11 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/app"
13 "github.com/charmbracelet/crush/internal/app"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/db"
15 "github.com/charmbracelet/crush/internal/db"
16+ "github.com/charmbracelet/crush/internal/event"
15 "github.com/charmbracelet/crush/internal/tui"
17 "github.com/charmbracelet/crush/internal/tui"
16 "github.com/charmbracelet/crush/internal/version"
18 "github.com/charmbracelet/crush/internal/version"
17 "github.com/charmbracelet/fang"
19 "github.com/charmbracelet/fang"
@@ -66,6 +68,8 @@ crush -y
66 }
68 }
67 defer app.Shutdown()
69 defer app.Shutdown()
68
70
71+ event.AppInitialized()
72+
69 // Set up the TUI.
73 // Set up the TUI.
70 program := tea.NewProgram(
74 program := tea.NewProgram(
71 tui.New(app),
75 tui.New(app),
@@ -78,11 +82,15 @@ crush -y
78 go app.Subscribe(program)
82 go app.Subscribe(program)
79
83
80 if _, err := program.Run(); err != nil {
84 if _, err := program.Run(); err != nil {
85+ event.Error(err)
81 slog.Error("TUI run error", "error", err)
86 slog.Error("TUI run error", "error", err)
82 return fmt.Errorf("TUI error: %v", err)
87 return fmt.Errorf("TUI error: %v", err)
83 }
88 }
84 return nil
89 return nil
85 },
90 },
91+ PostRun: func(cmd *cobra.Command, args []string) {
92+ event.AppExited()
93+ },
86}
94}
87
95
88func Execute() {
96func Execute() {
@@ -135,9 +143,26 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
135 return nil, err
143 return nil, err
136 }
144 }
137
145
146+ if shouldEnableMetrics() {
147+ event.Init()
148+ }
149+
138 return appInstance, nil
150 return appInstance, nil
139}
151}
140
152
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+
141func MaybePrependStdin(prompt string) (string, error) {
166func MaybePrependStdin(prompt string) (string, error) {
142 if term.IsTerminal(os.Stdin.Fd()) {
167 if term.IsTerminal(os.Stdin.Fd()) {
143 return prompt, nil
168 return prompt, nil
internal/config/config.go+1
@@ -153,6 +153,7 @@ type Options struct {
153 DisabledTools []string `json:"disabled_tools" jsonschema:"description=Tools to disable"`
153 DisabledTools []string `json:"disabled_tools" jsonschema:"description=Tools to disable"`
154 DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
154 DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
155 Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
155 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"`
156}
157}
157
158
158type MCPs map[string]MCPConfig
159type 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 (
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/csync"
15+ "github.com/charmbracelet/crush/internal/event"
15 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/llm/prompt"
17 "github.com/charmbracelet/crush/internal/llm/prompt"
17 "github.com/charmbracelet/crush/internal/llm/provider"
18 "github.com/charmbracelet/crush/internal/llm/provider"
@@ -25,12 +26,6 @@ import (
25 "github.com/charmbracelet/crush/internal/shell"
26 "github.com/charmbracelet/crush/internal/shell"
26)
27)
27
28
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−
34type AgentEventType string
29type AgentEventType string
35
30
36const (
31const (
@@ -66,10 +61,11 @@ type Service interface {
66
61
67type agent struct {
62type agent struct {
68 *pubsub.Broker[AgentEvent]
63 *pubsub.Broker[AgentEvent]
69− agentCfg config.Agent
64+ agentCfg config.Agent
70− sessions session.Service
65+ sessions session.Service
71− messages message.Service
66+ messages message.Service
67+ permissions permission.Service
72− mcpTools []McpTool
68+ mcpTools []McpTool
73
69
74 tools *csync.LazySlice[tools.BaseTool]
70 tools *csync.LazySlice[tools.BaseTool]
75 // We need this to be able to update it when model changes
71 // We need this to be able to update it when model changes
@@ -237,6 +233,7 @@ func NewAgent(
237 activeRequests: csync.NewMap[string, context.CancelFunc](),
233 activeRequests: csync.NewMap[string, context.CancelFunc](),
238 tools: csync.NewLazySlice(toolFn),
234 tools: csync.NewLazySlice(toolFn),
239 promptQueue: csync.NewMap[string, []string](),
235 promptQueue: csync.NewMap[string, []string](),
236+ permissions: permissions,
240 }, nil
237 }, nil
241}
238}
242
239
@@ -365,8 +362,9 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
365 }
362 }
366
363
367 genCtx, cancel := context.WithCancel(ctx)
364 genCtx, cancel := context.WithCancel(ctx)
368−
369 a.activeRequests.Set(sessionID, cancel)
365 a.activeRequests.Set(sessionID, cancel)
366+ startTime := time.Now()
367+
370 go func() {
368 go func() {
371 slog.Debug("Request started", "sessionID", sessionID)
369 slog.Debug("Request started", "sessionID", sessionID)
372 defer log.RecoverPanic("agent.Run", func() {
370 defer log.RecoverPanic("agent.Run", func() {
@@ -377,16 +375,24 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
377 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
375 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
378 }
376 }
379 result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
377 result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
380− if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
378+ if result.Error != nil {
379+ if isCancelledErr(result.Error) {
380+ slog.Error("Request canceled", "sessionID", sessionID)
381+ } else {
381− slog.Error(result.Error.Error())
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)
382 }
387 }
383− slog.Debug("Request completed", "sessionID", sessionID)
388+ a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
384 a.activeRequests.Del(sessionID)
389 a.activeRequests.Del(sessionID)
385 cancel()
390 cancel()
386 a.Publish(pubsub.CreatedEvent, result)
391 a.Publish(pubsub.CreatedEvent, result)
387 events <- result
392 events <- result
388 close(events)
393 close(events)
389 }()
394 }()
395+ a.eventPromptSent(sessionID)
390 return events, nil
396 return events, nil
391}
397}
392
398
@@ -726,13 +732,13 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
726 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
732 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
727 return fmt.Errorf("failed to update message: %w", err)
733 return fmt.Errorf("failed to update message: %w", err)
728 }
734 }
729− return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
735+ return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
730 }
736 }
731
737
732 return nil
738 return nil
733}
739}
734
740
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 {
736 sess, err := a.sessions.Get(ctx, sessionID)
742 sess, err := a.sessions.Get(ctx, sessionID)
737 if err != nil {
743 if err != nil {
738 return fmt.Errorf("failed to get session: %w", err)
744 return fmt.Errorf("failed to get session: %w", err)
@@ -743,6 +749,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.
743 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
749 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
744 model.CostPer1MOut/1e6*float64(usage.OutputTokens)
750 model.CostPer1MOut/1e6*float64(usage.OutputTokens)
745
751
752+ a.eventTokensUsed(sessionID, usage, cost)
753+
746 sess.Cost += cost
754 sess.Cost += cost
747 sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
755 sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
748 sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
756 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 (
9 "sync/atomic"
9 "sync/atomic"
10 "time"
10 "time"
11
11
12+ "github.com/charmbracelet/crush/internal/event"
12 "gopkg.in/natefinch/lumberjack.v2"
13 "gopkg.in/natefinch/lumberjack.v2"
13)
14)
14
15
@@ -48,6 +49,8 @@ func Initialized() bool {
48
49
49func RecoverPanic(name string, cleanup func()) {
50func RecoverPanic(name string, cleanup func()) {
50 if r := recover(); r != nil {
51 if r := recover(); r != nil {
52+ event.Error(r, "panic", true, "name", name)
53+
51 // Create a timestamped panic log file
54 // Create a timestamped panic log file
52 timestamp := time.Now().Format("20060102-150405")
55 timestamp := time.Now().Format("20060102-150405")
53 filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)
56 filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)
internal/session/session.go+3
@@ -5,6 +5,7 @@ import (
5 "database/sql"
5 "database/sql"
6
6
7 "github.com/charmbracelet/crush/internal/db"
7 "github.com/charmbracelet/crush/internal/db"
8+ "github.com/charmbracelet/crush/internal/event"
8 "github.com/charmbracelet/crush/internal/pubsub"
9 "github.com/charmbracelet/crush/internal/pubsub"
9 "github.com/google/uuid"
10 "github.com/google/uuid"
10)
11)
@@ -48,6 +49,7 @@ func (s *service) Create(ctx context.Context, title string) (Session, error) {
48 }
49 }
49 session := s.fromDBItem(dbSession)
50 session := s.fromDBItem(dbSession)
50 s.Publish(pubsub.CreatedEvent, session)
51 s.Publish(pubsub.CreatedEvent, session)
52+ event.SessionCreated()
51 return session, nil
53 return session, nil
52}
54}
53
55
@@ -89,6 +91,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
89 return err
91 return err
90 }
92 }
91 s.Publish(pubsub.DeletedEvent, session)
93 s.Publish(pubsub.DeletedEvent, session)
94+ event.SessionDeleted()
92 return nil
95 return nil
93}
96}
94
97
internal/tui/components/dialogs/sessions/sessions.go+2
@@ -4,6 +4,7 @@ import (
4 "github.com/charmbracelet/bubbles/v2/help"
4 "github.com/charmbracelet/bubbles/v2/help"
5 "github.com/charmbracelet/bubbles/v2/key"
5 "github.com/charmbracelet/bubbles/v2/key"
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/event"
7 "github.com/charmbracelet/crush/internal/session"
8 "github.com/charmbracelet/crush/internal/session"
8 "github.com/charmbracelet/crush/internal/tui/components/chat"
9 "github.com/charmbracelet/crush/internal/tui/components/chat"
9 "github.com/charmbracelet/crush/internal/tui/components/core"
10 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -99,6 +100,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99 selectedItem := s.sessionsList.SelectedItem()
100 selectedItem := s.sessionsList.SelectedItem()
100 if selectedItem != nil {
101 if selectedItem != nil {
101 selected := *selectedItem
102 selected := *selectedItem
103+ event.SessionSwitched()
102 return s, tea.Sequence(
104 return s, tea.Sequence(
103 util.CmdHandler(dialogs.CloseDialogMsg{}),
105 util.CmdHandler(dialogs.CloseDialogMsg{}),
104 util.CmdHandler(
106 util.CmdHandler(
internal/tui/tui.go+4
@@ -10,6 +10,7 @@ import (
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/app"
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/event"
13 "github.com/charmbracelet/crush/internal/llm/agent"
14 "github.com/charmbracelet/crush/internal/llm/agent"
14 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/pubsub"
16 "github.com/charmbracelet/crush/internal/pubsub"
@@ -196,6 +197,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
196 if a.app.CoderAgent.IsBusy() {
197 if a.app.CoderAgent.IsBusy() {
197 return a, util.ReportWarn("Agent is busy, please wait...")
198 return a, util.ReportWarn("Agent is busy, please wait...")
198 }
199 }
200+
199 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
201 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
200
202
201 // Update the agent with the new model/provider configuration
203 // Update the agent with the new model/provider configuration
@@ -211,6 +213,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
211
213
212 // File Picker
214 // File Picker
213 case commands.OpenFilePickerMsg:
215 case commands.OpenFilePickerMsg:
216+ event.FilePickerOpened()
217+
214 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
218 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
215 // If the commands dialog is already open, close it
219 // If the commands dialog is already open, close it
216 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
220 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
main.go+2
@@ -10,11 +10,13 @@ import (
10 _ "github.com/joho/godotenv/autoload" // automatically load .env files
10 _ "github.com/joho/godotenv/autoload" // automatically load .env files
11
11
12 "github.com/charmbracelet/crush/internal/cmd"
12 "github.com/charmbracelet/crush/internal/cmd"
13+ "github.com/charmbracelet/crush/internal/event"
13 "github.com/charmbracelet/crush/internal/log"
14 "github.com/charmbracelet/crush/internal/log"
14)
15)
15
16
16func main() {
17func main() {
17 defer log.RecoverPanic("main", func() {
18 defer log.RecoverPanic("main", func() {
19+ event.Flush()
18 slog.Error("Application terminated due to unhandled panic")
20 slog.Error("Application terminated due to unhandled panic")
19 })
21 })
20
22
schema.json+5
@@ -320,6 +320,11 @@
320 "attribution": {
320 "attribution": {
321 "$ref": "#/$defs/Attribution",
321 "$ref": "#/$defs/Attribution",
322 "description": "Attribution settings for generated content"
322 "description": "Attribution settings for generated content"
323+ },
324+ "disable_metrics": {
325+ "type": "boolean",
326+ "description": "Disable sending metrics",
327+ "default": false
323 }
328 }
324 },
329 },
325 "additionalProperties": false,
330 "additionalProperties": false,