Files
OpsLog/telemetry.go
T
2026-06-15 23:45:14 +02:00

130 lines
4.1 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.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])
}