Files
yetone 7d663a2769 providers, stats: count how often each partner is shown, opened, has its key page opened and is added, per day and capped, and send the days that have ended with the daily stats as "magpie partner" events; off with the stats or with what magpie is used with (yetone: 能否统计每个合作伙伴的展示数和点击数)
Ran: go test ./internal/provider ./internal/stats ./internal/gui ./internal/tui .;
partners.test.cjs (chromium + webkit), privacy-usage-stats, i18n-keys,
gui-ja, gui-de; GOOS=linux -tags nogui and GOOS=windows builds.
2026-10-09 01:40:02 +08:00

175 lines
5.1 KiB
Go

// 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, and, unless the user turned that part off, which
// agents, providers and models it is used with, by their built-in ids
// (Usage): a provider of the user's own is only "custom". No names, URLs,
// accounts, keys, prompts or usage go. 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/provider"
"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
}
at := now.UTC().Format(time.RFC3339)
event := func(name string, props map[string]any) map[string]any {
// a count, not a person: PostHog keeps no profile for the id
props["$process_person_profile"] = false
// PostHog's batch takes the id in either place: both, to be sure
props["distinct_id"] = id
return map[string]any{"event": name, "distinct_id": id, "timestamp": at, "properties": props}
}
active := map[string]any{
"version": version,
"kind": what,
"$os": osName(),
"arch": runtime.GOARCH,
}
var uses []map[string]any
if !settings.Load().NoUsageStats {
u := usage()
active["agents"] = len(u.Agents)
active["providers"] = len(u.Providers)
active["models"] = len(u.Models)
active["groups"] = u.Groups
// one event for each, so PostHog counts the users of each id
for _, l := range []struct {
kind string
ids []string
}{{"agent", u.Agents}, {"provider", u.Providers}, {"model", u.Models}} {
for _, x := range l.ids {
uses = append(uses, event("magpie uses", map[string]any{"kind": l.kind, "id": x}))
}
}
// a partner's counts of the days ended, each at its own day
for _, c := range provider.PartnerCounts(now) {
e := event("magpie partner", map[string]any{"id": c.ID, "what": c.What, "count": c.Count, "day": c.Day})
e["timestamp"] = c.Day + "T12:00:00Z"
uses = append(uses, e)
}
}
body, _ := json.Marshal(map[string]any{
"api_key": Key,
"batch": append([]map[string]any{event("magpie active", active)}, uses...),
})
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", Host()+"/batch/", 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)
}
if !settings.Load().NoUsageStats {
provider.PartnerCountsSent(now)
}
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
}