160 lines
5.5 KiB
Go
160 lines
5.5 KiB
Go
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.21.2"
|
||
|
||
// 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
|
||
}
|
||
|
||
// distinct_id identifies the USER. Prefer the station callsign — it's stable
|
||
// across every machine/reinstall the same operator runs, so one op counts as
|
||
// one user (a callsign is public in amateur radio). The random per-install ID
|
||
// is only a fallback; without it the same op on a laptop + desktop + Maestro
|
||
// showed up as three "users". install_id rides along as a property so multiple
|
||
// machines under one callsign stay visible.
|
||
//
|
||
// On a FRESH INSTALL the callsign isn't configured yet at launch, so sending
|
||
// straight away would record the machine's random UUID (and the once-a-day lock
|
||
// would then keep it that way even after the op enters their call). Instead wait
|
||
// a while for the callsign to appear (Settings → Station Information) and only
|
||
// fall back to the UUID if none shows up within the grace window — so a genuine
|
||
// no-callsign install is still counted, but the normal case gets the callsign.
|
||
call := a.activeCallsign()
|
||
for i := 0; call == "" && i < 60; i++ { // up to ~10 min (60 × 10 s)
|
||
time.Sleep(10 * time.Second)
|
||
if a.settings == nil || !a.GetTelemetryEnabled() {
|
||
return // disabled meanwhile
|
||
}
|
||
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
|
||
return // sent by another path in the meantime
|
||
}
|
||
call = a.activeCallsign()
|
||
}
|
||
installID := a.telemetryInstallID()
|
||
distinctID := installID
|
||
if call != "" {
|
||
distinctID = call
|
||
}
|
||
payload := map[string]any{
|
||
"api_key": posthogAPIKey,
|
||
"event": "app_opened",
|
||
"distinct_id": distinctID,
|
||
"properties": map[string]any{
|
||
"version": appVersion,
|
||
"os": runtime.GOOS,
|
||
"arch": runtime.GOARCH,
|
||
"install_id": installID,
|
||
"$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])
|
||
}
|