diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 3047ba8..c92e2a9 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -98,6 +98,8 @@ const en: Dict = {
'offline.synced': '{n} QSO(s) added to the logbook',
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
+ 'settings.telemetry': 'Send anonymous usage statistics',
+ 'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
'settings.mainView': 'Main view',
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
@@ -465,6 +467,8 @@ const fr: Dict = {
'offline.synced': '{n} QSO ajoutés au journal',
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
+ 'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
+ 'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
'settings.mainView': 'Vue principale',
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index f76e19a..da2d1b8 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -472,6 +472,8 @@ export function GetStationSettings():Promise
;
export function GetStationStatus():Promise>;
+export function GetTelemetryEnabled():Promise;
+
export function GetUIPref(arg1:string):Promise;
export function GetUltrabeamSettings():Promise;
@@ -876,6 +878,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise;
export function SetPassphrase(arg1:string):Promise;
+export function SetTelemetryEnabled(arg1:boolean):Promise;
+
export function SetUIPref(arg1:string,arg2:string):Promise;
export function SetUltrabeamDirection(arg1:number):Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index 95c38a9..77a100c 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -898,6 +898,10 @@ export function GetStationStatus() {
return window['go']['main']['App']['GetStationStatus']();
}
+export function GetTelemetryEnabled() {
+ return window['go']['main']['App']['GetTelemetryEnabled']();
+}
+
export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1);
}
@@ -1706,6 +1710,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
+export function SetTelemetryEnabled(arg1) {
+ return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
+}
+
export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
}
diff --git a/telemetry.go b/telemetry.go
new file mode 100644
index 0000000..ec0d5e6
--- /dev/null
+++ b/telemetry.go
@@ -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.20.11"
+
+ // 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])
+}
diff --git a/version.go b/version.go
deleted file mode 100644
index 9866c30..0000000
--- a/version.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package main
-
-// appVersion is this build's version. It's shown in the About/update flow, the
-// diagnostic-log e-mail and the multi-op live-status heartbeat. The release
-// script (.vscode/release.ps1) rewrites this line alongside frontend/version.ts.
-const appVersion = "0.20.11"
diff --git a/wiki/Security.md b/wiki/Security.md
index a2a6473..8f64968 100644
--- a/wiki/Security.md
+++ b/wiki/Security.md
@@ -18,5 +18,7 @@ them.
- **Callsign lookups** go to QRZ.com / HamQTH with your credentials.
- **QSL uploads/downloads** go to the services you configure (LoTW via TQSL, QRZ,
eQSL, ClubLog, HRDLog).
+- **Telemetry** is a once-a-day anonymous heartbeat (random install ID + version
+ + OS — no callsign, no QSO data). Opt out in Preferences.
- Everything else stays local (or on your own MySQL / web server for the
multi-op live status).
diff --git a/wiki/Settings-and-Data.md b/wiki/Settings-and-Data.md
index 8ded65c..e371aed 100644
--- a/wiki/Settings-and-Data.md
+++ b/wiki/Settings-and-Data.md
@@ -47,3 +47,5 @@ format, or an **ADIF record on each logged QSO** — so external tools stay in s
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch,
skipping any already running.
- **Update check** at startup (toggleable).
+- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
+ version + OS; no callsign or QSO data). Opt out in Preferences.