Files
OpsLog/telemetry.go
T
rouggy 51e279887d feat: Super Check Partial + N+1 helper; fix Flex binding to SmartSDR CAT; telemetry callsign wait
- SCP/N+1: new internal/scp downloads the community MASTER.SCP master list and a
  docked two-column widget shows, as you type a call, the known calls containing it
  (Partial) and the calls one edit away (N+1) — click to fix a busted call. Opt-in
  in Settings → General; top-bar toggle; queried debounced on callsign input.
- Flex: OpsLog's GUI-client detection was too loose and could bind to "SmartSDR CAT"
  (or DAX) — both carry "smartsdr" in the program name — instead of the real GUI
  client, making SmartSDR CAT drop and reconnect in a loop while OpsLog was open.
  Now it binds only to a real SmartSDR/Maestro GUI client (e.g. a FLEX-8600M's
  integrated screen) and excludes cat/dax; dropped the risky empty-program fallback.
- Telemetry: on a fresh install the callsign isn't set at launch, so the once-a-day
  heartbeat recorded the machine UUID. Now it waits (~10 min) for the operator to
  enter their callsign before sending, falling back to the UUID only if none appears.
2026-07-25 20:05:33 +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.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
}
// 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])
}