Files
OpsLog/telemetry.go
T
2026-07-25 20:05:50 +02:00

160 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}