feat: remove PostHog usage telemetry entirely

Drop the once-a-day anonymous "app_opened" heartbeat to PostHog and everything
around it: telemetry.go (sendTelemetryHeartbeat, GetTelemetryEnabled/
SetTelemetryEnabled, install-ID/last-sent settings keys, PostHog host + API key)
and the startup goroutine that fired it. The 'Send anonymous usage statistics'
toggle is removed from Settings → General (TelemetryToggle + i18n keys), and the
telemetry mentions are stripped from the README and wiki.

appVersion (still used by changelog/update/livestatus/log-email) moves to a new
version.go; release.ps1 now rewrites it there instead of telemetry.go.
This commit is contained in:
2026-07-24 11:52:52 +02:00
parent a14ade9277
commit 6a28344e93
11 changed files with 8 additions and 177 deletions
-2
View File
@@ -276,8 +276,6 @@ only writes to the DB — it is not a web server.
with a toast (toggleable), plus a **What's new** dialog that shows the changelog with a toast (toggleable), plus a **What's new** dialog that shows the changelog
(English / French) on the first launch after an update — reopenable any time (English / French) on the first launch after an update — reopenable any time
from the Help menu. from the Help menu.
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
version + OS — no callsign or QSO data; opt-out in Preferences).
--- ---
-3
View File
@@ -1120,9 +1120,6 @@ func (a *App) startup(ctx context.Context) {
} }
}() }()
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
// when disabled in Preferences or until the PostHog key is configured.
go a.sendTelemetryHeartbeat()
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
+2
View File
@@ -3,6 +3,7 @@
"version": "0.20.12", "version": "0.20.12",
"date": "2026-07-23", "date": "2026-07-23",
"en": [ "en": [
"Removed the anonymous usage telemetry entirely. OpsLog no longer sends the once-a-day heartbeat (it went to PostHog), and the 'Send anonymous usage statistics' option is gone from Preferences → General. Nothing about your usage leaves the app any more.",
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.", "FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.", "ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.", "Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
@@ -14,6 +15,7 @@
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored." "Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
], ],
"fr": [ "fr": [
"Suppression complète de la télémétrie d'usage anonyme. OpsLog n'envoie plus le battement quotidien (il allait vers PostHog), et l'option « Envoyer des statistiques d'usage anonymes » a disparu de Réglages → Général. Plus rien concernant ton usage ne quitte l'application.",
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.", "Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.", "L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.", "Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
-23
View File
@@ -32,7 +32,6 @@ import {
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
@@ -546,26 +545,6 @@ function AutostartPanelComponent() {
); );
} }
// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
// (a random install ID + version + OS, sent once a day). Real component so it
// can own its state; embedded inside GeneralPanel.
function TelemetryToggle() {
const { t } = useI18n();
const [on, setOn] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
}, []);
return (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
{t('settings.telemetry')}
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
</label>
);
}
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM, // ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a // VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
// contact that arrives is imported and uploaded automatically like any log entry. // contact that arrives is imported and uploaded automatically like any log entry.
@@ -4694,8 +4673,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} /> <Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span> {t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
</label> </label>
<TelemetryToggle />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} /> <MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
<div className="border-t border-border/60 pt-4 space-y-2"> <div className="border-t border-border/60 pt-4 space-y-2">
-4
View File
@@ -98,8 +98,6 @@ const en: Dict = {
'offline.synced': '{n} QSO(s) added to the logbook', 'offline.synced': '{n} QSO(s) added to the logbook',
'offline.stillDown': 'Database still unreachable — your QSOs are safe', 'offline.stillDown': 'Database still unreachable — your QSOs are safe',
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.', '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.mainView': 'Main view',
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).', 'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane', 'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
@@ -467,8 +465,6 @@ const fr: Dict = {
'offline.synced': '{n} QSO ajoutés au journal', 'offline.synced': '{n} QSO ajoutés au journal',
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité', '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.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.mainView': 'Vue principale',
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).", 'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit', 'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
-4
View File
@@ -472,8 +472,6 @@ export function GetStationSettings():Promise<main.StationSettings>;
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>; export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetUIPref(arg1:string):Promise<string>; export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>; export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -878,8 +876,6 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; export function SetUltrabeamDirection(arg1:number):Promise<void>;
-8
View File
@@ -898,10 +898,6 @@ export function GetStationStatus() {
return window['go']['main']['App']['GetStationStatus'](); return window['go']['main']['App']['GetStationStatus']();
} }
export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetUIPref(arg1) { export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1); return window['go']['main']['App']['GetUIPref'](arg1);
} }
@@ -1710,10 +1706,6 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['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) { export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2); return window['go']['main']['App']['SetUIPref'](arg1, arg2);
} }
-129
View File
@@ -1,129 +0,0 @@
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])
}
+6
View File
@@ -0,0 +1,6 @@
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"
-2
View File
@@ -18,7 +18,5 @@ them.
- **Callsign lookups** go to QRZ.com / HamQTH with your credentials. - **Callsign lookups** go to QRZ.com / HamQTH with your credentials.
- **QSL uploads/downloads** go to the services you configure (LoTW via TQSL, QRZ, - **QSL uploads/downloads** go to the services you configure (LoTW via TQSL, QRZ,
eQSL, ClubLog, HRDLog). 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 - Everything else stays local (or on your own MySQL / web server for the
multi-op live status). multi-op live status).
-2
View File
@@ -47,5 +47,3 @@ 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, - **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch,
skipping any already running. skipping any already running.
- **Update check** at startup (toggleable). - **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.