package main import ( "bytes" "crypto/rand" "encoding/json" "fmt" "net/http" "runtime" "strings" "time" "hamlog/internal/applog" ) // Anonymous usage telemetry - a once-a-day "app_opened" heartbeat to PostHog so // the OpsLog author can see how many people actively use it. Privacy by design: // only a random install ID + app version + OS are sent (no callsign, no QSO // data, no IP beyond what any HTTP request reveals). Users can disable it in // Preferences -> General. See [[user-analytics-posthog]] notes in MEMORY. const ( // appVersion is stamped on every heartbeat (and could feed the About box). appVersion = "0.13" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project. posthogHost = "https://eu.i.posthog.com" // posthogAPIKey is the PostHog PROJECT API key ("phc_..."). It's a public, // write-only ingestion key (safe to ship in the binary, like the ClubLog app // key). Until it's filled in, telemetry is a no-op. posthogAPIKey = "phc_vumvN7XTERNhmRzMZHNgY5DncZfFibTbomiE9epZvUJ4" keyTelemetryEnabled = "telemetry.enabled" // "0" disables; default on keyTelemetryInstallID = "telemetry.install_id" // random, stable per install keyTelemetryLastSent = "telemetry.last_sent" // YYYY-MM-DD of last heartbeat ) // GetTelemetryEnabled reports whether anonymous usage stats are on (default on). func (a *App) GetTelemetryEnabled() bool { if a.settings == nil { return true } v, _ := a.settings.GetGlobal(a.ctx, keyTelemetryEnabled) return strings.TrimSpace(v) != "0" } // SetTelemetryEnabled turns anonymous usage stats on or off. func (a *App) SetTelemetryEnabled(on bool) error { if a.settings == nil { return fmt.Errorf("db not initialized") } val := "1" if !on { val = "0" } return a.settings.SetGlobal(a.ctx, keyTelemetryEnabled, val) } // telemetryInstallID returns this install's stable anonymous ID, generating and // persisting one on first use. func (a *App) telemetryInstallID() string { id, _ := a.settings.GetGlobal(a.ctx, keyTelemetryInstallID) if id = strings.TrimSpace(id); id != "" { return id } id = randomUUID() _ = a.settings.SetGlobal(a.ctx, keyTelemetryInstallID, id) return id } // sendTelemetryHeartbeat sends at most one "app_opened" event per calendar day // (UTC). Best effort: any failure is logged and ignored. Runs in a goroutine at // startup. func (a *App) sendTelemetryHeartbeat() { if a.settings == nil || !a.GetTelemetryEnabled() { return } if strings.Contains(posthogAPIKey, "REPLACE") || strings.TrimSpace(posthogAPIKey) == "" { return // not configured yet } today := time.Now().UTC().Format("2006-01-02") if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today { return // already counted today } payload := map[string]any{ "api_key": posthogAPIKey, "event": "app_opened", "distinct_id": a.telemetryInstallID(), "properties": map[string]any{ "version": appVersion, "os": runtime.GOOS, "arch": runtime.GOARCH, "$lib": "opslog", }, "timestamp": time.Now().UTC().Format(time.RFC3339), } body, err := json.Marshal(payload) if err != nil { return } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Post(posthogHost+"/capture/", "application/json", bytes.NewReader(body)) if err != nil { applog.Printf("telemetry: heartbeat failed: %v", err) return } resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { _ = a.settings.SetGlobal(a.ctx, keyTelemetryLastSent, today) applog.Printf("telemetry: heartbeat ok (%s)", today) } else { applog.Printf("telemetry: heartbeat HTTP %d", resp.StatusCode) } } // randomUUID returns a random RFC-4122 v4 UUID string. func randomUUID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { // Extremely unlikely; fall back to a time-based id so we still get one. return fmt.Sprintf("ts-%d", time.Now().UnixNano()) } b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant 10 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) }