From 3f3cdff11987fdd9d6c56bfde61df97bfa2947f5 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 28 Sep 2026 17:23:39 +0800 Subject: [PATCH] =?UTF-8?q?stats:=20magpie=20counts=20its=20users=20?= =?UTF-8?q?=E2=80=94=20once=20a=20day=20a=20running=20app=20or=20`magpie?= =?UTF-8?q?=20serve`=20sends=20PostHog=20(the=20Magpie=20project)=20one=20?= =?UTF-8?q?"magpie=20active"=20event=20under=20a=20random=20id=20made=20up?= =?UTF-8?q?=20on=20this=20computer=20(~/.config/magpie/install-id),=20with?= =?UTF-8?q?=20the=20version,=20the=20system=20and=20the=20architecture=20a?= =?UTF-8?q?nd=20nothing=20else,=20no=20person=20profile=20kept;=20off=20in?= =?UTF-8?q?=20Settings=20=E2=86=92=20Privacy=20=E2=86=92=20Count=20me=20as?= =?UTF-8?q?=20a=20user,=20or=20with=20DO=5FNOT=5FTRACK=3D1=20or=20MAGPIE?= =?UTF-8?q?=5FNO=5FSTATS=3D1,=20and=20never=20from=20a=20build=20from=20so?= =?UTF-8?q?urce;=20the=20README=20says=20what=20goes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +++ internal/gui/app.go | 2 + internal/gui/assets/app.js | 4 +- internal/gui/assets/i18n.js | 2 + internal/settings/settings.go | 3 + internal/stats/stats.go | 140 ++++++++++++++++++++++++++++++++++ internal/stats/stats_test.go | 55 +++++++++++++ providers_cli.go | 2 + 8 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 internal/stats/stats.go create mode 100644 internal/stats/stats_test.go diff --git a/README.md b/README.md index 3bfd97ba..767dea62 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,16 @@ stdin. `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are respected. +## Counting users + +Once a day, a running magpie (the app, or `magpie serve`) sends one event to +PostHog so we know how many people use it: a random id made up on your +computer (`~/.config/magpie/install-id`), magpie's version, and your system +and architecture. Nothing else goes: no accounts, keys, providers, models, +prompts or usage. Turn it off in Settings → Privacy → Count me as a user, or +with `DO_NOT_TRACK=1` or `MAGPIE_NO_STATS=1`. Builds from source never send +it. The code is [internal/stats](internal/stats/stats.go). + ## Community Questions, setups worth sharing, ideas, bugs: come talk to us and other diff --git a/internal/gui/app.go b/internal/gui/app.go index 359af6a7..2efc2982 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -21,6 +21,7 @@ import ( "github.com/yetone/magpie/internal/library" "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/stats" "github.com/yetone/magpie/internal/update" ) @@ -166,6 +167,7 @@ func Run(version string, showMain bool, link string) error { theme = "&theme=" + t } h := &host{query: theme, ready: make(chan struct{})} + go stats.Run(version, "app") handler := devShell(h) if handler == nil { handler = Handler(h, startBackend()) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index c2d2c3ee..a9dd9e2a 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -5331,6 +5331,8 @@ function renderRedact(s, keep) { i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") save(); else if (e.key === "Escape") { i.value = words; i.blur(); } }; i.onblur = save; row(t("Masked words"), t("Your own words to keep from vendors, separated by commas"), i); + row(t("Count me as a user"), t("Once a day, a random id for this computer with magpie's version and system — nothing you use magpie for"), + onOff(!s.noStats, (on) => savePrefs({ ...keep, noStats: !on }))); } // renderLAN: the gateway shared on the local network, for agents on other @@ -5423,7 +5425,7 @@ async function renderUpdate(r, u) { function prefsKeep(s) { return { theme: s.theme, lang: s.lang, tray: s.tray, dock: !!s.dock, dockWindow: !!s.dockWindow, proxy: s.proxy || "", redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "", - claudeWarmup: s.claudeWarmup || "", + claudeWarmup: s.claudeWarmup || "", noStats: !!s.noStats, trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3 }; } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 01f37517..8e2928cc 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -92,6 +92,8 @@ const I18N = { "Other computers send it as their API key; a new one stops the old from working": "其他电脑把它填作 API Key;换新后旧的立即失效", "API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "API Key、私钥、Token 和密码以占位符发给模型厂商,回复里再还原", "Mask personal data": "脱敏个人信息", + "Count me as a user": "计入用户数", + "Once a day, a random id for this computer with magpie's version and system — nothing you use magpie for": "每天一次,只发送本机的随机 ID、magpie 版本和系统,不含你用 magpie 做的任何事", "Codex warm-up": "Codex 预热", "Claude Code warm-up": "Claude Code 预热", "Usage in the menu bar": "菜单栏显示用量", diff --git a/internal/settings/settings.go b/internal/settings/settings.go index bea4ac34..dcc85917 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -54,6 +54,9 @@ type Settings struct { // ClaudeWarmup is CodexWarmup for the Claude accounts, the request // sent through Claude Code. ClaudeWarmup string `json:"claudeWarmup,omitempty"` + // NoStats stops the one event a day that counts magpie's users (see + // internal/stats). + NoStats bool `json:"noStats,omitempty"` // TrayUsage is the subscription or plan whose windows are shown beside // the tray icon, by its provider and account ("claude|a@b.c"); "" none. TrayUsage string `json:"trayUsage,omitempty"` diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 00000000..f9483544 --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,140 @@ +// Package stats counts magpie's users: once a day a running magpie tells +// PostHog it is in use, under an id made up on this computer, with its +// version and system. Nothing else goes: no names, accounts, keys, models, +// prompts or usage. Settings → Privacy turns it off, and so do +// DO_NOT_TRACK=1 and MAGPIE_NO_STATS=1; a build from source never sends it. +package stats + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/update" +) + +// Key is the Magpie project's key on PostHog: it can send events there and +// read nothing, so it is safe in the app. +const Key = "phc_nyDyYyrUTRGvNq5U7FBesCyM2j4GdKeqGp8ko7DBerTC" + +// Host takes the events; MAGPIE_STATS_HOST points them elsewhere, for +// testing. +func Host() string { + if h := os.Getenv("MAGPIE_STATS_HOST"); h != "" { + return strings.TrimRight(h, "/") + } + return "https://us.i.posthog.com" +} + +// Off reports whether the user said not to send it. +func Off() bool { + for _, k := range []string{"DO_NOT_TRACK", "MAGPIE_NO_STATS"} { + if v := os.Getenv(k); v != "" && v != "0" && v != "false" { + return true + } + } + return settings.Load().NoStats +} + +// Run sends the day's event while magpie runs, as what ("app", "serve"): at +// start, and then whenever a new day (UTC) has begun. Only releases send +// it, unless MAGPIE_STATS_HOST is set. +func Run(version, what string) { + if !update.Released(version) && os.Getenv("MAGPIE_STATS_HOST") == "" { + return + } + for { + // no network, most likely, when it fails: the next look tries again + _ = Send(context.Background(), version, what, time.Now()) + time.Sleep(time.Hour) + } +} + +// Send sends the event for now's day, unless it went already or the user +// turned it off. +func Send(ctx context.Context, version, what string, now time.Time) error { + if Off() { + return nil + } + day := now.UTC().Format("2006-01-02") + stamp := filepath.Join(settings.Dir(), "stats-sent") + if b, _ := os.ReadFile(stamp); strings.TrimSpace(string(b)) == day { + return nil + } + id, err := installID() + if err != nil { + return err + } + body, _ := json.Marshal(map[string]any{ + "api_key": Key, + "event": "magpie active", + "distinct_id": id, + "timestamp": now.UTC().Format(time.RFC3339), + "properties": map[string]any{ + "version": version, + "kind": what, + "$os": osName(), + "arch": runtime.GOARCH, + // a count, not a person: PostHog keeps no profile for the id + "$process_person_profile": false, + }, + }) + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", Host()+"/i/v0/e/", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + res.Body.Close() + if res.StatusCode/100 != 2 { + return fmt.Errorf("stats: %s", res.Status) + } + return os.WriteFile(stamp, []byte(day+"\n"), 0o644) +} + +// installID is this computer's id, made up the first time: random, tied to +// nothing about it or the user. +func installID() (string, error) { + p := filepath.Join(settings.Dir(), "install-id") + if b, err := os.ReadFile(p); err == nil { + if id := strings.TrimSpace(string(b)); len(id) == 32 { + return id, nil + } + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + id := hex.EncodeToString(b) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return "", err + } + return id, os.WriteFile(p, []byte(id+"\n"), 0o644) +} + +func osName() string { + switch runtime.GOOS { + case "darwin": + return "Mac OS X" + case "windows": + return "Windows" + case "linux": + return "Linux" + } + return runtime.GOOS +} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go new file mode 100644 index 00000000..b5ecd246 --- /dev/null +++ b/internal/stats/stats_test.go @@ -0,0 +1,55 @@ +package stats + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/yetone/magpie/internal/settings" +) + +// One event a day, under the same id, with nothing but the id, the version +// and the system; none once the user turns it off. +func TestSend(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("MAGPIE_NO_STATS", "") + var got []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var e map[string]any + json.NewDecoder(r.Body).Decode(&e) + if r.URL.Path != "/i/v0/e/" || e["api_key"] != Key { + t.Errorf("sent %s %v", r.URL.Path, e) + } + got = append(got, e) + })) + defer srv.Close() + t.Setenv("MAGPIE_STATS_HOST", srv.URL) + day := time.Date(2026, 9, 28, 9, 0, 0, 0, time.UTC) + for _, at := range []time.Time{day, day.Add(3 * time.Hour), day.Add(24 * time.Hour)} { + if err := Send(context.Background(), "0.1.300", "app", at); err != nil { + t.Fatal(err) + } + } + if len(got) != 2 { + t.Fatalf("%d events for two days", len(got)) + } + if got[0]["distinct_id"] != got[1]["distinct_id"] || len(got[0]["distinct_id"].(string)) != 32 { + t.Fatalf("ids: %v %v", got[0]["distinct_id"], got[1]["distinct_id"]) + } + p := got[0]["properties"].(map[string]any) + if p["version"] != "0.1.300" || p["kind"] != "app" || len(p) != 5 { + t.Fatalf("properties: %v", p) + } + settings.Save(settings.Settings{NoStats: true}) + Send(context.Background(), "0.1.300", "app", day.Add(48*time.Hour)) + t.Setenv("MAGPIE_NO_STATS", "1") + settings.Save(settings.Settings{}) + Send(context.Background(), "0.1.300", "app", day.Add(72*time.Hour)) + if len(got) != 2 { + t.Fatalf("sent while off: %d", len(got)) + } +} diff --git a/providers_cli.go b/providers_cli.go index b55c5baf..b95e70ed 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -15,6 +15,7 @@ import ( "github.com/yetone/magpie/internal/agent" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/stats" ) var amber = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#F2B544"}) @@ -726,6 +727,7 @@ func refreshLive(ctx context.Context) { // serve: `magpie serve` — the gateway alone, in the foreground. func serve() error { s := gateway.New() + go stats.Run(version, "serve") fmt.Println(green.Render("●"), "magpie gateway on", bold.Render(gateway.URL())) fmt.Println(muted.Render(" OpenAI "), gateway.URL()+"/v1/chat/completions", muted.Render("·"), gateway.URL()+"/v1/responses") fmt.Println(muted.Render(" Anthropic"), gateway.URL()+"/v1/messages")