stats: magpie counts its users — once a day a running app or magpie serve sends PostHog (the Magpie project) one "magpie active" event under a random id made up on this computer (~/.config/magpie/install-id), with the version, the system and the architecture and nothing else, no person profile kept; off in Settings → Privacy → Count me as a user, or with DO_NOT_TRACK=1 or MAGPIE_NO_STATS=1, and never from a build from source; the README says what goes
This commit is contained in:
8 files changed
+217
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "菜单栏显示用量",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user