up
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
||||
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.1"
|
||||
|
||||
// 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])
|
||||
}
|
||||
Reference in New Issue
Block a user