From 58e67c7b19b70d6d94bbc5698bf051ff255fff73 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 14 Apr 2026 11:05:10 +0100 Subject: [PATCH] Canonicalize usage-data telemetry reporting --- README.md | 2 +- docs/AI.md | 2 +- docs/CONFIGURATION.md | 6 +- docs/PRIVACY.md | 42 ++- .../v6/internal/subsystems/agent-lifecycle.md | 7 + .../v6/internal/subsystems/api-contracts.md | 9 + .../subsystems/deployment-installability.md | 6 + .../subsystems/frontend-primitives.md | 9 + .../v6/internal/subsystems/registry.json | 8 +- .../internal/subsystems/security-privacy.md | 17 +- .../internal/subsystems/storage-recovery.md | 7 + frontend-modern/public/docs/CONFIGURATION.md | 6 +- frontend-modern/public/docs/PRIVACY.md | 42 ++- .../src/api/__tests__/settings.test.ts | 3 + frontend-modern/src/api/settings.ts | 5 + .../Settings/GeneralSettingsPanel.tsx | 24 +- .../GeneralSettingsPanel.guardrails.test.ts | 2 + .../__tests__/settingsArchitecture.test.ts | 2 + .../__tests__/useSystemSettingsState.test.ts | 9 + .../Settings/useSystemSettingsState.ts | 6 +- .../shared/__tests__/WhatsNewModal.test.tsx | 1 + .../components/shared/whatsNewModalModel.ts | 4 +- .../stores/__tests__/systemSettings.test.ts | 1 + .../systemSettingsPresentation.test.ts | 2 +- .../src/utils/systemSettingsPresentation.ts | 2 +- internal/api/router_routes_licensing.go | 2 +- internal/config/config.go | 8 +- internal/telemetry/telemetry.go | 46 ++- internal/telemetry/telemetry_test.go | 49 ++- internal/updates/version.go | 56 +++ internal/updates/version_test.go | 84 +++++ pkg/server/server.go | 12 +- scripts/telemetry_adoption_report.py | 350 ++++++++++++++++++ .../tests/test_telemetry_adoption_report.py | 96 +++++ .../tests/19-telemetry-disclosure.spec.ts | 10 +- 35 files changed, 834 insertions(+), 103 deletions(-) create mode 100644 scripts/telemetry_adoption_report.py create mode 100644 scripts/tests/test_telemetry_adoption_report.py diff --git a/README.md b/README.md index 87a4b2fccd..29972e6eac 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Power-user shortcuts: - **One-Click Updates**: Easy upgrades for supported deployments - **OIDC/SSO/SAML**: Single sign-on with multi-provider support - **Mobile Remote Access**: Relay protocol with end-to-end encryption for supported Pulse Mobile clients (Relay and above) -- **Privacy Focused**: Anonymous telemetry is enabled by default and [fully documented](docs/PRIVACY.md) — no hostnames, credentials, or personal data is ever sent. Disable any time in Settings or via `PULSE_TELEMETRY=false`. +- **Privacy Focused**: Anonymous outbound telemetry is enabled by default and [fully documented](docs/PRIVACY.md) — no hostnames, credentials, or personal data is ever sent. Disable any time in Settings or via `PULSE_TELEMETRY=false`. ## ⚡ Quick Start diff --git a/docs/AI.md b/docs/AI.md index 8254deee78..9610760a83 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -428,7 +428,7 @@ Use this only in trusted environments. ## Privacy -Patrol runs on your server and only sends the minimal context needed for analysis to the configured provider (when AI is enabled). Anonymous telemetry (counts and feature flags only — no hostnames or credentials) is enabled by default and can be disabled any time — see [Privacy](PRIVACY.md) for details. +Patrol runs on your server and only sends the minimal context needed for analysis to the configured provider (when AI is enabled). Anonymous outbound telemetry (counts and feature flags only — no hostnames or credentials) is enabled by default and can be disabled any time — see [Privacy](PRIVACY.md) for details. --- diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5957bedbb5..44d4817d10 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -183,7 +183,7 @@ Numeric intervals are **seconds** unless noted otherwise. | `metricsRetentionDailyDays` | Daily metrics retention (days) | | `disableDockerUpdateActions` | Hide Docker update actions in UI | | `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) | -| `disableLocalUpgradeMetrics` | Disable local-only upgrade metrics collection | +| `disableLocalUpgradeMetrics` | Disable local-only upgrade event collection | | `backendPort` | Legacy (unused) | | `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) | @@ -275,8 +275,8 @@ When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame | `DNS_CACHE_TIMEOUT` | Cache TTL for DNS lookups | `5m` | | `MAX_POLL_TIMEOUT` | Maximum time per polling cycle | `3m` | | `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` | -| `PULSE_DISABLE_LOCAL_UPGRADE_METRICS` | Disable local-only upgrade metrics collection | `false` | -| `PULSE_TELEMETRY` | Anonymous usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` | +| `PULSE_DISABLE_LOCAL_UPGRADE_METRICS` | Disable local-only upgrade event collection | `false` | +| `PULSE_TELEMETRY` | Anonymous outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` | ### Logging Overrides diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 36deb4ce38..c7f1abe20e 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -2,31 +2,45 @@ Pulse is designed to run locally. By default, your monitoring data stays on your server. -## Anonymous Telemetry +## Usage Data -Pulse includes anonymous telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help the developer understand how many active installations exist and which features are in use. +Pulse currently has two usage-data scopes: + +1. **Anonymous outbound telemetry** to help me understand active installations, release uptake, and feature use in aggregate. +2. **Local-only upgrade events** to help debug and improve upgrade flows on the instance where they were recorded. + +These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only upgrade-event path stays on the Pulse instance and is not exported to third parties. + +### Anonymous outbound telemetry + +Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, and which features are in use. No hostnames, credentials, IP addresses, or personally identifiable information is ever sent. See the full field list below. -### How to disable +#### How to disable -- **Settings → System → General → Anonymous telemetry** (toggle off), or +- **Settings → System → General → Anonymous outbound telemetry** (toggle off), or - Set the environment variable `PULSE_TELEMETRY=false` -### How to inspect or rotate it +#### How to inspect or rotate it - **Settings → System → General → Preview payload** shows the exact heartbeat JSON Pulse would send with the current runtime state. - **Settings → System → General → Reset ID** immediately rotates the local telemetry install ID and refreshes the previewed payload. - If telemetry is currently disabled, the preview still shows the payload Pulse would send if you enable it. -### Exactly what is sent +#### Exactly what is sent Every field is listed below with the reason it exists — nothing else leaves your server: | Field | Example | Purpose | |-------|---------|---------| | Install ID | `a1b2c3d4-...` | Distinguish active installations within one rotation window without tying telemetry to an account or person | -| Version | `6.0.0` | See which releases are actually deployed so compatibility and upgrade issues can be prioritized | +| Version | `6.0.0-rc.1` | Track the canonical release identity currently deployed | +| Version raw | `v6.0.0-rc.1-45-gabcdef` | Preserve the original build string when it differs so manual/dev builds do not pollute release reporting | +| Version channel | `stable`, `rc`, `dev` | Distinguish published stable/RC assets from development or prerelease builds | +| Version build | `git.45.gabcdef` | Preserve build metadata for git-describe and other non-release builds | +| Version is development | `true`/`false` | Mark manual or source-built development installs explicitly | +| Version is published release | `true`/`false` | Mark whether the running build matches a published stable or RC release asset | | Platform | `docker` or `binary` | Understand whether runtime behavior differs between container and non-container installs | | OS | `linux` | See whether operating-system-specific issues exist | | Arch | `amd64` | See whether CPU-architecture-specific issues exist | @@ -46,13 +60,13 @@ Every field is listed below with the reason it exists — nothing else leaves yo | Paid license | `true`/`false` | Distinguish free from paid posture without sending the exact commercial tier | | Has API tokens | `true`/`false` | See whether token-based automation/integration is being used without sending token counts | -### Server-side handling and retention +#### Server-side handling and retention - Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis. - Telemetry rows older than **90 days** are purged automatically. - The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows. -### What is NOT sent +#### What is NOT sent - No IP addresses are stored in telemetry rows - No hostnames, node names, VM names, or any infrastructure identifiers @@ -60,14 +74,14 @@ Every field is listed below with the reason it exists — nothing else leaves yo - No alert content, AI prompts, or chat messages - No personally identifiable information of any kind -### Install ID rotation +#### Install ID rotation The telemetry install ID is pseudonymous and rotates automatically every 30 days. Pulse keeps it only to avoid treating every startup ping as a brand-new install while still limiting long-term linkage from one heartbeat window to the next. Operators can also rotate it immediately from **Settings → System → General → Reset ID**. -### Source code +#### Source code The telemetry implementation is in [`internal/telemetry/telemetry.go`](../internal/telemetry/telemetry.go). You can read the `Ping` struct to see every field that is transmitted. @@ -84,12 +98,12 @@ Pulse can make outbound connections when you enable specific features: - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable mobile access. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. -## Local Upgrade Metrics (Can Be Disabled) +### Local-only upgrade events -Pulse can record local-only events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows. +Pulse can record local-only usage events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows. - These events are stored locally and are not exported to third parties. -- Disable via **Settings → System → General → Disable local upgrade metrics** or set: +- Disable via **Settings → System → General → Disable local-only upgrade events** or set: - `PULSE_DISABLE_LOCAL_UPGRADE_METRICS=true` If you prefer fewer upgrade prompts, you can also enable: diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 2fccf18bd2..7d3ac042c7 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -415,6 +415,13 @@ optional optimistic hostnames. Shared row models may fall back to the row name when staging a removal state, but they must not resurrect legacy `policy.display` shims or require platform-managed surfaces to synthesize a second hostname contract. +That same adjacent `internal/api/` router boundary now also keeps usage-data +controls out of lifecycle truth. Agent install, reporting, and setup surfaces +may coexist with `/api/upgrade-metrics/*` and telemetry preview routes under +shared licensing/auth routing, but they must not reinterpret local-only +upgrade-event state, telemetry preview payloads, or published-release +classification fields as enrollment evidence, agent freshness, or setup +progress truth. That same adjacent `internal/api/` boundary now also keeps public demos from leaking commercial state through lifecycle-adjacent surfaces. Agent install, reporting, and setup flows may share backend helpers with billing or license diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b5edd10b49..437fb01f44 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -494,6 +494,15 @@ when the disabled candidate no longer counts toward monitored-system capacity. The API layer already uses contract tests in many places, but every major live contract should continue moving toward canonical-only runtime shapes. +That same shared settings/licensing contract now also owns the split usage-data +payload model. `frontend-modern/src/api/settings.ts`, +`internal/api/router_routes_licensing.go`, and adjacent settings callers must +keep anonymous outbound telemetry and local-only upgrade events as separate +browser-visible scopes, and the telemetry preview payload must ship normalized +version identity fields (`version`, `version_raw`, `version_channel`, +`version_build`, `version_is_development`, and +`version_is_published_release`) instead of leaving browser callers to infer +published-release truth from raw build strings. That same browser-transport contract now tolerates sparse admission-preview payloads without changing the runtime truth. Patrol transport may omit `finding_ids`, and infrastructure removal previews may stage optimistic rows diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c136c954d1..4938734961 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -151,6 +151,12 @@ from `git describe`. Non-published prerelease bases such as `6.0.0-dev` therefore remain prerelease for branch policy, release-control blocked records, and future release promotion planning, but they must not be treated as shipped RC lineage or as published release-asset versions. +That same version boundary now also owns the canonical usage-data release +identity. `internal/updates/version.go` must classify raw runtime version +strings into normalized release identity fields for browser preview payloads +and operator telemetry reporting, so unpublished `git describe` / manual / dev +builds cannot pollute published stable or RC adoption reads just because they +share a semver-looking prefix. That release-build metadata path is now explicit too: `scripts/release_ldflags.sh` is the canonical owner for server and agent build ldflags, and release artifact assembly must route through it instead of hand-writing overlapping `main.Version`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e660834b3d..4d104b11fd 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -502,6 +502,15 @@ bounds, including provider-backed alert-history wording. `frontend-modern/src/fe backed host and VM incidents with the shared `resource-incident` vocabulary and existing alert-history shells instead of introducing VMware-only labels, badges, or panel copy just because the underlying signal came from vSphere. +That same shared settings and modal boundary now also owns the public usage-data +vocabulary. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, +`frontend-modern/src/components/shared/whatsNewModalModel.ts`, +`frontend-modern/src/components/Settings/useSystemSettingsState.ts`, and +`frontend-modern/src/utils/systemSettingsPresentation.ts` must present one +explicit `Usage data and privacy` model with the separate labels +`Anonymous outbound telemetry` and `Disable local-only upgrade events`, and +must describe telemetry with normalized release identity rather than falling +back to ambiguous `telemetry`, `upgrade metrics`, or raw-version wording. Shared table, disclosure, and form primitives must also stay explicitly typed at the browser edge. Summary rows may memoize repeated pending-update reads, shared buttons must preserve discriminated disclosure props, toggle and a11y diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 85bfc5c26d..6bdd0441c7 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4062,6 +4062,7 @@ "internal/api/security_tokens.go", "internal/api/system_settings.go", "internal/telemetry/telemetry.go", + "scripts/telemetry_adoption_report.py", "SECURITY.md" ], "verification": { @@ -4079,7 +4080,8 @@ "internal/api/security_tokens_owner_binding_test.go", "internal/api/security_tokens_test.go", "internal/api/system_settings_telemetry_test.go", - "internal/telemetry/telemetry_test.go" + "internal/telemetry/telemetry_test.go", + "scripts/tests/test_telemetry_adoption_report.py" ], "require_explicit_path_policy_coverage": true, "path_policies": [ @@ -4091,6 +4093,7 @@ "docs/PRIVACY.md", "frontend-modern/src/components/Settings/useSystemSettingsState.ts", "internal/telemetry/telemetry.go", + "scripts/telemetry_adoption_report.py", "SECURITY.md" ], "allow_same_subsystem_tests": false, @@ -4098,7 +4101,8 @@ "exact_files": [ "frontend-modern/src/stores/__tests__/systemSettings.test.ts", "internal/api/system_settings_telemetry_test.go", - "internal/telemetry/telemetry_test.go" + "internal/telemetry/telemetry_test.go", + "scripts/tests/test_telemetry_adoption_report.py" ] }, { diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 0254ec4fb7..094f27ca39 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -17,9 +17,10 @@ ## Purpose -Own Pulse's canonical privacy disclosures, anonymous telemetry boundary, and -the security-facing settings surfaces that expose authentication posture, -token-management visibility, and privacy controls to operators. +Own Pulse's canonical privacy disclosures, usage-data boundary (anonymous +outbound telemetry plus local-only upgrade events), and the security-facing +settings surfaces that expose authentication posture, token-management +visibility, and privacy controls to operators. ## Canonical Files @@ -48,6 +49,7 @@ token-management visibility, and privacy controls to operators. 23. `internal/api/system_settings.go` 24. `internal/telemetry/telemetry.go` 25. `internal/api/router_routes_auth_security.go` +26. `scripts/telemetry_adoption_report.py` ## Shared Boundaries @@ -65,11 +67,12 @@ token-management visibility, and privacy controls to operators. ## Extension Points -1. Change privacy disclosures or outbound-data guarantees through `docs/PRIVACY.md` and `internal/telemetry/telemetry.go` together. +1. Change privacy disclosures, usage-data vocabulary, or outbound-data guarantees through `docs/PRIVACY.md` and `internal/telemetry/telemetry.go` together. 2. Change security policy, hardening guidance, or supported auth boundaries through `SECURITY.md`. 3. Change telemetry/privacy settings state handling through `frontend-modern/src/components/Settings/useSystemSettingsState.ts`. 4. Change security/auth/token transport behavior through the shared `frontend-modern/src/api/security.ts`, `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`, `internal/api/security.go`, `internal/api/security_tokens.go`, and `internal/api/system_settings.go` boundary. 5. Change security/privacy settings presentation through the shared `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`, `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`, `frontend-modern/src/components/Settings/QuickSecuritySetup.tsx`, `frontend-modern/src/components/Settings/SecurityPostureSummary.tsx`, `frontend-modern/src/components/Settings/SSOProviderTypeIcon.tsx`, `frontend-modern/src/utils/securityAuthPresentation.ts`, `frontend-modern/src/utils/securityScorePresentation.ts`, `frontend-modern/src/utils/auditLogPresentation.ts`, and `frontend-modern/src/utils/auditWebhookPresentation.ts` boundary. +6. Change operator-facing telemetry/adoption reporting through `scripts/telemetry_adoption_report.py` together with the privacy disclosure whenever release-identity interpretation changes. ## Forbidden Paths @@ -82,13 +85,17 @@ token-management visibility, and privacy controls to operators. 1. Update privacy/security docs and the telemetry runtime together when outbound-data behavior changes. 2. Keep shared API-contract proof routing aligned whenever auth, token, or telemetry settings payloads change. 3. Keep shared frontend settings proof routing aligned whenever security/privacy presentation changes. -4. Update this contract whenever a new canonical security, token, auth, or privacy surface becomes part of the governed trust boundary. +4. Keep the checked-in telemetry adoption report aligned with the same release-identity rules used by the runtime telemetry payload. +5. Update this contract whenever a new canonical security, token, auth, or privacy surface becomes part of the governed trust boundary. ## Current State This subsystem now gives `L14` an explicit governed home for privacy guidance and telemetry disclosures instead of leaving those trust surfaces as lane-level evidence with no subsystem ownership. +That same governed home now also owns the single "usage data" vocabulary for +anonymous outbound telemetry and local-only upgrade events, so Pulse stops +describing those two privacy scopes as unrelated systems. Security-facing settings remain intentionally shared with `frontend-primitives` because shell framing and presentation consistency still belong there, but the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7b5dc0044e..717ebd26b8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -491,6 +491,13 @@ fields and nullable mode/kind metadata before presenting canonical item labels, while storage detail drawers and filter controls must route summary series IDs, source tones, and disk metrics through the shared storage helpers instead of reconstructing them from local table state. +That same adjacent `internal/api/` router boundary now also keeps usage-data +transport descriptive-only for storage and recovery. Shared storage/recovery +surfaces may coexist with `/api/upgrade-metrics/*` config reads and telemetry +preview routes under the licensing/settings router, but they must not treat +local-only upgrade-event toggles, telemetry preview payloads, or normalized +release-classification fields as storage freshness, recovery evidence, or +operator-facing protection state. That same shared `internal/api/` dependency now also expects replacement-aware monitored-system admission and fail-closed usage availability. Storage- or recovery-adjacent setup, deploy, and API-backed update helpers may reuse the diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 5957bedbb5..44d4817d10 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -183,7 +183,7 @@ Numeric intervals are **seconds** unless noted otherwise. | `metricsRetentionDailyDays` | Daily metrics retention (days) | | `disableDockerUpdateActions` | Hide Docker update actions in UI | | `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) | -| `disableLocalUpgradeMetrics` | Disable local-only upgrade metrics collection | +| `disableLocalUpgradeMetrics` | Disable local-only upgrade event collection | | `backendPort` | Legacy (unused) | | `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) | @@ -275,8 +275,8 @@ When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame | `DNS_CACHE_TIMEOUT` | Cache TTL for DNS lookups | `5m` | | `MAX_POLL_TIMEOUT` | Maximum time per polling cycle | `3m` | | `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` | -| `PULSE_DISABLE_LOCAL_UPGRADE_METRICS` | Disable local-only upgrade metrics collection | `false` | -| `PULSE_TELEMETRY` | Anonymous usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` | +| `PULSE_DISABLE_LOCAL_UPGRADE_METRICS` | Disable local-only upgrade event collection | `false` | +| `PULSE_TELEMETRY` | Anonymous outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` | ### Logging Overrides diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 36deb4ce38..c7f1abe20e 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -2,31 +2,45 @@ Pulse is designed to run locally. By default, your monitoring data stays on your server. -## Anonymous Telemetry +## Usage Data -Pulse includes anonymous telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help the developer understand how many active installations exist and which features are in use. +Pulse currently has two usage-data scopes: + +1. **Anonymous outbound telemetry** to help me understand active installations, release uptake, and feature use in aggregate. +2. **Local-only upgrade events** to help debug and improve upgrade flows on the instance where they were recorded. + +These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only upgrade-event path stays on the Pulse instance and is not exported to third parties. + +### Anonymous outbound telemetry + +Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, and which features are in use. No hostnames, credentials, IP addresses, or personally identifiable information is ever sent. See the full field list below. -### How to disable +#### How to disable -- **Settings → System → General → Anonymous telemetry** (toggle off), or +- **Settings → System → General → Anonymous outbound telemetry** (toggle off), or - Set the environment variable `PULSE_TELEMETRY=false` -### How to inspect or rotate it +#### How to inspect or rotate it - **Settings → System → General → Preview payload** shows the exact heartbeat JSON Pulse would send with the current runtime state. - **Settings → System → General → Reset ID** immediately rotates the local telemetry install ID and refreshes the previewed payload. - If telemetry is currently disabled, the preview still shows the payload Pulse would send if you enable it. -### Exactly what is sent +#### Exactly what is sent Every field is listed below with the reason it exists — nothing else leaves your server: | Field | Example | Purpose | |-------|---------|---------| | Install ID | `a1b2c3d4-...` | Distinguish active installations within one rotation window without tying telemetry to an account or person | -| Version | `6.0.0` | See which releases are actually deployed so compatibility and upgrade issues can be prioritized | +| Version | `6.0.0-rc.1` | Track the canonical release identity currently deployed | +| Version raw | `v6.0.0-rc.1-45-gabcdef` | Preserve the original build string when it differs so manual/dev builds do not pollute release reporting | +| Version channel | `stable`, `rc`, `dev` | Distinguish published stable/RC assets from development or prerelease builds | +| Version build | `git.45.gabcdef` | Preserve build metadata for git-describe and other non-release builds | +| Version is development | `true`/`false` | Mark manual or source-built development installs explicitly | +| Version is published release | `true`/`false` | Mark whether the running build matches a published stable or RC release asset | | Platform | `docker` or `binary` | Understand whether runtime behavior differs between container and non-container installs | | OS | `linux` | See whether operating-system-specific issues exist | | Arch | `amd64` | See whether CPU-architecture-specific issues exist | @@ -46,13 +60,13 @@ Every field is listed below with the reason it exists — nothing else leaves yo | Paid license | `true`/`false` | Distinguish free from paid posture without sending the exact commercial tier | | Has API tokens | `true`/`false` | See whether token-based automation/integration is being used without sending token counts | -### Server-side handling and retention +#### Server-side handling and retention - Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis. - Telemetry rows older than **90 days** are purged automatically. - The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows. -### What is NOT sent +#### What is NOT sent - No IP addresses are stored in telemetry rows - No hostnames, node names, VM names, or any infrastructure identifiers @@ -60,14 +74,14 @@ Every field is listed below with the reason it exists — nothing else leaves yo - No alert content, AI prompts, or chat messages - No personally identifiable information of any kind -### Install ID rotation +#### Install ID rotation The telemetry install ID is pseudonymous and rotates automatically every 30 days. Pulse keeps it only to avoid treating every startup ping as a brand-new install while still limiting long-term linkage from one heartbeat window to the next. Operators can also rotate it immediately from **Settings → System → General → Reset ID**. -### Source code +#### Source code The telemetry implementation is in [`internal/telemetry/telemetry.go`](../internal/telemetry/telemetry.go). You can read the `Ping` struct to see every field that is transmitted. @@ -84,12 +98,12 @@ Pulse can make outbound connections when you enable specific features: - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable mobile access. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. -## Local Upgrade Metrics (Can Be Disabled) +### Local-only upgrade events -Pulse can record local-only events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows. +Pulse can record local-only usage events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows. - These events are stored locally and are not exported to third parties. -- Disable via **Settings → System → General → Disable local upgrade metrics** or set: +- Disable via **Settings → System → General → Disable local-only upgrade events** or set: - `PULSE_DISABLE_LOCAL_UPGRADE_METRICS=true` If you prefer fewer upgrade prompts, you can also enable: diff --git a/frontend-modern/src/api/__tests__/settings.test.ts b/frontend-modern/src/api/__tests__/settings.test.ts index 19d87c3af5..a2ad7af131 100644 --- a/frontend-modern/src/api/__tests__/settings.test.ts +++ b/frontend-modern/src/api/__tests__/settings.test.ts @@ -49,6 +49,9 @@ describe('SettingsAPI', () => { payload: { install_id: 'preview-install-id', version: '6.0.0', + version_channel: 'stable', + version_is_development: false, + version_is_published_release: true, platform: 'docker', os: 'linux', arch: 'amd64', diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index f5b95a4366..9cd2860413 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -8,6 +8,11 @@ export interface SystemSettingsResponse extends SystemConfig { export interface TelemetryPingPreview { install_id: string; version: string; + version_raw?: string; + version_channel: string; + version_build?: string; + version_is_development: boolean; + version_is_published_release: boolean; platform: string; os: string; arch: string; diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index ca90695a84..edc4dbdfc5 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -175,10 +175,10 @@ export const GeneralSettingsPanel: Component = (props - {/* Navigation + Privacy Card */} + {/* Usage Data + Privacy Card */} } noPadding bodyClass="divide-y divide-border" @@ -187,16 +187,16 @@ export const GeneralSettingsPanel: Component = (props
- Disable local upgrade metrics + Disable local-only upgrade events

- Records local-only events like "paywall viewed" and "trial started" to help debug and - improve upgrade flows. These events are stored locally and are not exported to third - parties. + Records local-only upgrade events like "paywall viewed" and "trial started" to help + debug and improve upgrade flows. These events stay on this Pulse instance and are not + exported to third parties.

= (props
- Anonymous telemetry + Anonymous outbound telemetry

- Help improve Pulse by sharing anonymous usage data: a rotating install ID, - version, platform, resource counts, and feature flags. No hostnames, - credentials, or personal information are sent. Telemetry rows are retained for up - to 90 days, and IP addresses are not stored in telemetry rows.{' '} + Help improve Pulse by sharing anonymous outbound usage data: a rotating install ID, + normalized release identity, platform, resource counts, and feature flags. No + hostnames, credentials, or personal information are sent. Telemetry rows are + retained for up to 90 days, and IP addresses are not stored in telemetry rows.{' '} { }); it('keeps telemetry preview and reset controls in the general settings panel', () => { + expect(generalSettingsPanelSource).toContain('Usage data and privacy'); + expect(generalSettingsPanelSource).toContain('Disable local-only upgrade events'); expect(generalSettingsPanelSource).toContain('Preview payload'); expect(generalSettingsPanelSource).toContain('Reset ID'); expect(generalSettingsPanelSource).toContain('Telemetry payload preview'); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 7be1445d32..51754dc4ab 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1349,6 +1349,8 @@ describe('Settings architecture guardrails', () => { expect(generalSettingsPanelSource).toContain('@/utils/systemSettingsPresentation'); expect(generalSettingsPanelSource).toContain('@/utils/docsLinks'); expect(generalSettingsPanelSource).toContain('PRIVACY_DOC_URL'); + expect(generalSettingsPanelSource).toContain('Usage data and privacy'); + expect(generalSettingsPanelSource).toContain('Disable local-only upgrade events'); expect(generalSettingsPanelSource).toContain('Preview payload'); expect(generalSettingsPanelSource).toContain('Reset ID'); expect(generalSettingsPanelSource).toContain('Telemetry payload preview'); diff --git a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts index 4c986db9c0..12692660bf 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts @@ -308,6 +308,9 @@ describe('useSystemSettingsState', () => { payload: { install_id: 'preview-install-id', version: '6.0.0', + version_channel: 'stable', + version_is_development: false, + version_is_published_release: true, platform: 'docker', os: 'linux', arch: 'amd64', @@ -346,6 +349,9 @@ describe('useSystemSettingsState', () => { payload: { install_id: 'preview-install-id', version: '6.0.0', + version_channel: 'stable', + version_is_development: false, + version_is_published_release: true, platform: 'docker', os: 'linux', arch: 'amd64', @@ -389,6 +395,9 @@ describe('useSystemSettingsState', () => { payload: { install_id: 'rotated-install-id', version: '6.0.0', + version_channel: 'stable', + version_is_development: false, + version_is_published_release: true, platform: 'binary', os: 'linux', arch: 'amd64', diff --git a/frontend-modern/src/components/Settings/useSystemSettingsState.ts b/frontend-modern/src/components/Settings/useSystemSettingsState.ts index abd9b2a72c..3f25b63d47 100644 --- a/frontend-modern/src/components/Settings/useSystemSettingsState.ts +++ b/frontend-modern/src/components/Settings/useSystemSettingsState.ts @@ -364,11 +364,11 @@ export function useSystemSettingsState({ await SettingsAPI.updateSystemSettings({ disableLocalUpgradeMetrics: disabled }); updateDisableLocalUpgradeMetricsSetting(disabled); notificationStore.success( - disabled ? 'Local upgrade metrics disabled' : 'Local upgrade metrics enabled', + disabled ? 'Local-only upgrade events disabled' : 'Local-only upgrade events enabled', 2000, ); } catch (error) { - logger.error('Failed to update local upgrade metrics setting', error); + logger.error('Failed to update local-only upgrade events setting', error); notificationStore.error( getLocalUpgradeMetricsUpdateErrorMessage( error instanceof Error ? error.message : undefined, @@ -392,7 +392,7 @@ export function useSystemSettingsState({ try { await SettingsAPI.updateSystemSettings({ telemetryEnabled: enabled }); notificationStore.success( - enabled ? 'Anonymous telemetry enabled' : 'Anonymous telemetry disabled', + enabled ? 'Anonymous outbound telemetry enabled' : 'Anonymous outbound telemetry disabled', 3000, ); } catch (error) { diff --git a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx index 3dfdeefa64..6482ff2156 100644 --- a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx @@ -52,6 +52,7 @@ describe('WhatsNewModal', () => { expect(whatsNewModalModelSource).toContain('README_DOC_URL'); expect(whatsNewModalModelSource).toContain('PRIVACY_DOC_URL'); expect(whatsNewModalModelSource).toContain('rotating install ID'); + expect(whatsNewModalModelSource).toContain('normalized release identity'); expect(whatsNewModalModelSource).toContain('IP addresses are not stored in telemetry rows'); expect(whatsNewModalModelSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/README.md'); expect(whatsNewModalModelSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md'); diff --git a/frontend-modern/src/components/shared/whatsNewModalModel.ts b/frontend-modern/src/components/shared/whatsNewModalModel.ts index 4f6204120c..1fa611a3c9 100644 --- a/frontend-modern/src/components/shared/whatsNewModalModel.ts +++ b/frontend-modern/src/components/shared/whatsNewModalModel.ts @@ -41,9 +41,9 @@ export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [ export const WHATS_NEW_TITLE = 'Welcome to the New Navigation!'; export const WHATS_NEW_SUBTITLE = 'Everything is now organized by what you want to do, not where the data comes from.'; -export const WHATS_NEW_TELEMETRY_TITLE = 'Anonymous telemetry'; +export const WHATS_NEW_TELEMETRY_TITLE = 'Anonymous outbound telemetry'; export const WHATS_NEW_TELEMETRY_COPY = [ - 'Pulse now sends a lightweight anonymous ping once a day — just a rotating install ID, version, platform, resource counts, and feature flags. No hostnames, credentials, or personal information are sent, and IP addresses are not stored in telemetry rows.', + 'Pulse now sends a lightweight anonymous ping once a day — just a rotating install ID, normalized release identity, platform, resource counts, and feature flags. No hostnames, credentials, or personal information are sent, and IP addresses are not stored in telemetry rows.', 'This helps the developer understand how Pulse is used and prioritise what to build next.', ]; export const WHATS_NEW_TELEMETRY_SETTINGS_PATH = 'Settings → System → General'; diff --git a/frontend-modern/src/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index 1a6f784cc3..1ba75d48be 100644 --- a/frontend-modern/src/stores/__tests__/systemSettings.test.ts +++ b/frontend-modern/src/stores/__tests__/systemSettings.test.ts @@ -67,6 +67,7 @@ describe('systemSettings store', () => { it('documents telemetry retention and field-level rationale in the privacy doc', () => { const privacyDoc = readFileSync(path.join(repoRoot, 'docs', 'PRIVACY.md'), 'utf8'); + expect(privacyDoc).toContain('## Usage Data'); expect(privacyDoc).toContain('Every field is listed below with the reason it exists'); expect(privacyDoc).toContain('rows older than **90 days** are purged automatically'); expect(privacyDoc).toContain('uses client IP addresses transiently for abuse/rate limiting'); diff --git a/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts b/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts index b3af54868a..7b191f9c7c 100644 --- a/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts @@ -86,7 +86,7 @@ describe('systemSettingsPresentation', () => { 'Unable to update upgrade guidance preferences.', ); expect(getLocalUpgradeMetricsUpdateErrorMessage()).toBe( - 'Unable to update local upgrade metrics.', + 'Unable to update local-only upgrade events.', ); expect(getTelemetryUpdateErrorMessage()).toBe('Unable to update anonymous telemetry.'); expect(getTemperatureMonitoringUpdateErrorMessage()).toBe( diff --git a/frontend-modern/src/utils/systemSettingsPresentation.ts b/frontend-modern/src/utils/systemSettingsPresentation.ts index 6735ea2a6f..cdf39133e2 100644 --- a/frontend-modern/src/utils/systemSettingsPresentation.ts +++ b/frontend-modern/src/utils/systemSettingsPresentation.ts @@ -81,7 +81,7 @@ export function getReduceUpsellNoiseUpdateErrorMessage(message?: string): string } export function getLocalUpgradeMetricsUpdateErrorMessage(message?: string): string { - return message || 'Unable to update local upgrade metrics.'; + return message || 'Unable to update local-only upgrade events.'; } export function getTelemetryUpdateErrorMessage(message?: string): string { diff --git a/internal/api/router_routes_licensing.go b/internal/api/router_routes_licensing.go index 8eefe519db..49b2485ad7 100644 --- a/internal/api/router_routes_licensing.go +++ b/internal/api/router_routes_licensing.go @@ -45,7 +45,7 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan r.mux.HandleFunc("POST /api/license/monitored-system-ledger/preview", RequireAuth(r.config, r.handleMonitoredSystemLedgerPreview)) r.mux.HandleFunc("POST /api/license/trial/start", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleStartTrial))) - // Local upgrade metrics (formerly "conversion" telemetry). Canonical routes: + // Local-only upgrade events (legacy route names retain "upgrade-metrics" for compatibility). // These are local-only signals used to improve in-app upgrade flows; no external export. r.mux.HandleFunc("POST /api/upgrade-metrics/events", RequireAuth(r.config, conversionHandlers.HandleRecordEvent)) r.mux.HandleFunc("GET /api/upgrade-metrics/stats", RequireAuth(r.config, conversionHandlers.HandleGetStats)) diff --git a/internal/config/config.go b/internal/config/config.go index 92bafe0198..6734633bb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -172,8 +172,8 @@ type Config struct { AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"` HideLocalLogin bool `envconfig:"PULSE_AUTH_HIDE_LOCAL_LOGIN" default:"false"` DisableDockerUpdateActions bool `envconfig:"PULSE_DISABLE_DOCKER_UPDATE_ACTIONS" default:"false"` // Hide Docker update buttons (read-only mode for containers) - DisableLocalUpgradeMetrics bool `envconfig:"PULSE_DISABLE_LOCAL_UPGRADE_METRICS" default:"false"` // Disable local-only upgrade UX metrics collection - TelemetryEnabled bool `envconfig:"PULSE_TELEMETRY" default:"true"` // Anonymous telemetry enabled by default (install ID, version, resource counts, feature flags — opt out any time) + DisableLocalUpgradeMetrics bool `envconfig:"PULSE_DISABLE_LOCAL_UPGRADE_METRICS" default:"false"` // Disable local-only upgrade event collection + TelemetryEnabled bool `envconfig:"PULSE_TELEMETRY" default:"true"` // Anonymous outbound usage telemetry enabled by default (install ID, version, resource counts, feature flags — opt out any time) MultiTenantEnabled bool `envconfig:"PULSE_MULTI_TENANT_ENABLED" default:"false"` // Enable multi-tenant support MetricsToken string `envconfig:"PULSE_METRICS_TOKEN" default:"" json:"-"` // Bearer token for /metrics endpoint (empty = unauthenticated) ProTrialSignupURL string `envconfig:"PULSE_PRO_TRIAL_SIGNUP_URL" default:""` // Hosted signup/checkout URL for starting Pulse Pro trials @@ -1010,7 +1010,7 @@ func load(initLogging bool) (*Config, error) { cfg.DisableLocalUpgradeMetrics = disabled cfg.EnvOverrides["PULSE_DISABLE_LOCAL_UPGRADE_METRICS"] = true cfg.EnvOverrides["disableLocalUpgradeMetrics"] = true - log.Info().Bool("disabled", disabled).Msg("Overriding local upgrade metrics setting from environment") + log.Info().Bool("disabled", disabled).Msg("Overriding local-only upgrade events setting from environment") } else { log.Warn().Str("value", disableLocalUpgradeMetricsStr).Msg("Invalid PULSE_DISABLE_LOCAL_UPGRADE_METRICS value, ignoring") } @@ -1021,7 +1021,7 @@ func load(initLogging bool) (*Config, error) { cfg.TelemetryEnabled = enabled cfg.EnvOverrides["PULSE_TELEMETRY"] = true cfg.EnvOverrides["telemetryEnabled"] = true - log.Info().Bool("enabled", enabled).Msg("Overriding telemetry setting from environment") + log.Info().Bool("enabled", enabled).Msg("Overriding anonymous outbound telemetry setting from environment") } else { log.Warn().Str("value", telemetryStr).Msg("Invalid PULSE_TELEMETRY value, ignoring") } diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 3dd6111913..13d2f287ca 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -8,7 +8,7 @@ // // Identity: // - A rotating install ID (UUID, generated locally and rotated periodically, not tied to any account) -// - Pulse version +// - Pulse version identity (normalized version plus raw build string when it differs) // - Platform: "docker" or "binary" // - OS and architecture (e.g. "linux/amd64") // @@ -37,7 +37,7 @@ // # How to disable // // Set the environment variable PULSE_TELEMETRY=false, or toggle off -// "Anonymous telemetry" in Settings → System → General. +// "Anonymous outbound telemetry" in Settings → System → General. package telemetry import ( @@ -54,6 +54,7 @@ import ( "time" "github.com/google/uuid" + "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rs/zerolog/log" ) @@ -96,12 +97,17 @@ type installIDRecord struct { // Every field is documented here so users can audit exactly what leaves their server. type Ping struct { // Identity - InstallID string `json:"install_id"` // Rotating UUID, not tied to any account - Version string `json:"version"` // Pulse version (e.g. "6.0.0") - Platform string `json:"platform"` // "docker" or "binary" - OS string `json:"os"` // runtime.GOOS (e.g. "linux") - Arch string `json:"arch"` // runtime.GOARCH (e.g. "amd64") - Event string `json:"event"` // "startup" or "heartbeat" + InstallID string `json:"install_id"` // Rotating UUID, not tied to any account + Version string `json:"version"` // Normalized Pulse version (e.g. "6.0.0-rc.1") + VersionRaw string `json:"version_raw,omitempty"` // Original version/build string when it differs + VersionChannel string `json:"version_channel"` // "stable", "rc", "dev", or "prerelease" + VersionBuild string `json:"version_build,omitempty"` // Build metadata when present (e.g. git describe suffix) + VersionDevelopment bool `json:"version_is_development"` // True for development/manual builds + VersionPublished bool `json:"version_is_published_release"` // True for published stable/RC asset versions + Platform string `json:"platform"` // "docker" or "binary" + OS string `json:"os"` // runtime.GOOS (e.g. "linux") + Arch string `json:"arch"` // runtime.GOARCH (e.g. "amd64") + Event string `json:"event"` // "startup" or "heartbeat" // Scale (counts only — no names, IPs, or identifiers) PVENodes int `json:"pve_nodes"` @@ -166,15 +172,15 @@ var ( current *runner ) -// Start begins anonymous telemetry if enabled. +// Start begins anonymous outbound telemetry if enabled. // It reads or creates a rotating install ID in dataDir, waits for the monitor // to populate state, sends a startup ping, and schedules a daily heartbeat. // Call Stop() on shutdown. // -// This is a no-op when telemetry is not opted in. +// This is a no-op when anonymous outbound telemetry is not opted in. func Start(ctx context.Context, cfg Config) { if !cfg.Enabled { - log.Info().Msg("Anonymous telemetry is disabled (enable via PULSE_TELEMETRY=true or Settings → System)") + log.Info().Msg("Anonymous outbound telemetry is disabled (enable via PULSE_TELEMETRY=true or Settings → System)") return } @@ -198,7 +204,7 @@ func Start(ctx context.Context, cfg Config) { log.Info(). Str("platform", base.Platform). - Msg("Anonymous telemetry enabled — sends a rotating install ID, version, platform, OS/arch, resource counts, and feature flags (nothing else)") + Msg("Anonymous outbound telemetry enabled — sends a rotating install ID, version identity, platform, OS/arch, resource counts, and feature flags (nothing else)") r.wg.Add(1) go func() { @@ -282,12 +288,18 @@ func jitteredHeartbeat() time.Duration { } func basePing(cfg Config, installID string) Ping { + versionIdentity := updates.DescribeUsageDataVersion(cfg.Version) return Ping{ - InstallID: installID, - Version: cfg.Version, - Platform: platformName(cfg.IsDocker), - OS: runtime.GOOS, - Arch: runtime.GOARCH, + InstallID: installID, + Version: versionIdentity.Version, + VersionRaw: versionIdentity.RawVersion, + VersionChannel: versionIdentity.Channel, + VersionBuild: versionIdentity.Build, + VersionDevelopment: versionIdentity.IsDevelopment, + VersionPublished: versionIdentity.IsPublishedRelease, + Platform: platformName(cfg.IsDocker), + OS: runtime.GOOS, + Arch: runtime.GOARCH, } } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 84a079193f..e1dc678271 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -199,7 +199,7 @@ func TestBuildPreview_UsesCurrentHeartbeatPayload(t *testing.T) { dir := t.TempDir() preview, err := BuildPreview(Config{ - Version: "6.0.0", + Version: "v6.0.0-rc.1-45-gABCDEF", DataDir: dir, IsDocker: true, GetSnapshot: func() Snapshot { @@ -221,6 +221,24 @@ func TestBuildPreview_UsesCurrentHeartbeatPayload(t *testing.T) { if preview.Platform != "docker" { t.Fatalf("preview platform = %q, want docker", preview.Platform) } + if preview.Version != "6.0.0-rc.1+git.45.gabcdef" { + t.Fatalf("preview version = %q, want normalized development version", preview.Version) + } + if preview.VersionRaw != "v6.0.0-rc.1-45-gABCDEF" { + t.Fatalf("preview raw version = %q, want original version string", preview.VersionRaw) + } + if preview.VersionChannel != "dev" { + t.Fatalf("preview version channel = %q, want dev", preview.VersionChannel) + } + if preview.VersionBuild != "git.45.gabcdef" { + t.Fatalf("preview version build = %q, want git.45.gabcdef", preview.VersionBuild) + } + if !preview.VersionDevelopment { + t.Fatal("expected preview to mark development build") + } + if preview.VersionPublished { + t.Fatal("development preview must not be marked as published release") + } if preview.PVENodes != 3 || preview.VMs != 10 || preview.ActiveAlerts != 2 { t.Fatalf("preview snapshot = %#v", preview) } @@ -255,12 +273,16 @@ func TestSend_Success(t *testing.T) { defer cancel() ping := Ping{ - InstallID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - Version: "6.0.0", - Event: "startup", - Platform: "docker", - OS: "linux", - Arch: "amd64", + InstallID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Version: "6.0.0-rc.1", + VersionRaw: "v6.0.0-rc.1", + VersionChannel: "rc", + VersionDevelopment: false, + VersionPublished: true, + Event: "startup", + Platform: "docker", + OS: "linux", + Arch: "amd64", } send(ctx, ping) @@ -273,8 +295,17 @@ func TestSend_Success(t *testing.T) { if lastPing.Event != "startup" { t.Errorf("event = %q, want %q", lastPing.Event, "startup") } - if lastPing.Version != "6.0.0" { - t.Errorf("version = %q, want %q", lastPing.Version, "6.0.0") + if lastPing.Version != "6.0.0-rc.1" { + t.Errorf("version = %q, want %q", lastPing.Version, "6.0.0-rc.1") + } + if lastPing.VersionRaw != "v6.0.0-rc.1" { + t.Errorf("version_raw = %q, want %q", lastPing.VersionRaw, "v6.0.0-rc.1") + } + if lastPing.VersionChannel != "rc" { + t.Errorf("version_channel = %q, want rc", lastPing.VersionChannel) + } + if !lastPing.VersionPublished { + t.Error("expected version_is_published_release to be true") } } diff --git a/internal/updates/version.go b/internal/updates/version.go index 019cb41d09..04b907ce6d 100644 --- a/internal/updates/version.go +++ b/internal/updates/version.go @@ -64,6 +64,18 @@ type VersionInfo struct { DeploymentType string `json:"deploymentType"` } +// UsageDataVersionIdentity is the canonical release/build identity that usage-data +// surfaces should use when distinguishing published releases from development or +// manual branch builds. +type UsageDataVersionIdentity struct { + Version string `json:"version"` + RawVersion string `json:"version_raw,omitempty"` + Channel string `json:"version_channel"` + Build string `json:"version_build,omitempty"` + IsDevelopment bool `json:"version_is_development"` + IsPublishedRelease bool `json:"version_is_published_release"` +} + // ParseVersion parses a version string into a Version struct func ParseVersion(versionStr string) (*Version, error) { // Remove 'v' prefix if present @@ -197,6 +209,32 @@ func GetCurrentVersion() (*VersionInfo, error) { return buildInfo("4.26.0", "release", false), nil } +// DescribeUsageDataVersion normalizes a reported version string into a stable +// release/build identity for telemetry and operator-facing usage-data reports. +func DescribeUsageDataVersion(raw string) UsageDataVersionIdentity { + trimmed := strings.TrimSpace(raw) + normalized := normalizeVersionString(trimmed) + identity := UsageDataVersionIdentity{ + Version: normalized, + Channel: "unknown", + } + if trimmed != "" && trimmed != normalized { + identity.RawVersion = trimmed + } + + parsed, err := ParseVersion(normalized) + if err != nil { + return identity + } + + identity.Build = parsed.Build + identity.Channel = usageDataVersionChannel(parsed) + identity.IsDevelopment = identity.Channel == "dev" + identity.IsPublishedRelease = (identity.Channel == "stable" || identity.Channel == "rc") && + parsed.IsPublishedReleaseAssetVersion() + return identity +} + // normalizeVersionString ensures any version string can be parsed as semantic version. // Release builds are returned unchanged, while git describe strings and branch names are // converted to a safe 0.0.0-based prerelease format. @@ -328,6 +366,24 @@ func detectChannelFromVersion(version string) string { return "stable" } +func usageDataVersionChannel(version *Version) string { + if version == nil { + return "unknown" + } + switch { + case version.Build != "": + return "dev" + case version.IsRCPrerelease(): + return "rc" + case version.Prerelease == "": + return "stable" + case strings.EqualFold(version.Prerelease, "dev"), strings.HasPrefix(strings.ToLower(version.Prerelease), "dev."): + return "dev" + default: + return "prerelease" + } +} + func readCurrentVersionFile() (string, bool) { versionPaths := []string{ "VERSION", diff --git a/internal/updates/version_test.go b/internal/updates/version_test.go index 0df5b0f248..5ea4feaf6c 100644 --- a/internal/updates/version_test.go +++ b/internal/updates/version_test.go @@ -276,6 +276,90 @@ func TestVersionString(t *testing.T) { } } +func TestDescribeUsageDataVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantVersion string + wantRaw string + wantChannel string + wantBuild string + wantDevelopment bool + wantPublished bool + }{ + { + name: "published rc strips accidental v prefix", + input: "v6.0.0-rc.1", + wantVersion: "6.0.0-rc.1", + wantRaw: "v6.0.0-rc.1", + wantChannel: "rc", + wantPublished: true, + }, + { + name: "git describe rc is development not published release", + input: "v6.0.0-rc.1-45-gABCDEF", + wantVersion: "6.0.0-rc.1+git.45.gabcdef", + wantRaw: "v6.0.0-rc.1-45-gABCDEF", + wantChannel: "dev", + wantBuild: "git.45.gabcdef", + wantDevelopment: true, + wantPublished: false, + }, + { + name: "source branch falls back to prerelease dev identity", + input: "feature/new-usage-report", + wantVersion: "0.0.0-feature-new-usage-report", + wantRaw: "feature/new-usage-report", + wantChannel: "prerelease", + wantDevelopment: false, + wantPublished: false, + }, + { + name: "stable release stays stable and published", + input: "6.0.0", + wantVersion: "6.0.0", + wantChannel: "stable", + wantPublished: true, + }, + { + name: "explicit dev prerelease is not published", + input: "6.0.0-dev", + wantVersion: "6.0.0-dev", + wantChannel: "dev", + wantDevelopment: true, + wantPublished: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := DescribeUsageDataVersion(tc.input) + if got.Version != tc.wantVersion { + t.Fatalf("DescribeUsageDataVersion(%q).Version = %q, want %q", tc.input, got.Version, tc.wantVersion) + } + if got.RawVersion != tc.wantRaw { + t.Fatalf("DescribeUsageDataVersion(%q).RawVersion = %q, want %q", tc.input, got.RawVersion, tc.wantRaw) + } + if got.Channel != tc.wantChannel { + t.Fatalf("DescribeUsageDataVersion(%q).Channel = %q, want %q", tc.input, got.Channel, tc.wantChannel) + } + if got.Build != tc.wantBuild { + t.Fatalf("DescribeUsageDataVersion(%q).Build = %q, want %q", tc.input, got.Build, tc.wantBuild) + } + if got.IsDevelopment != tc.wantDevelopment { + t.Fatalf("DescribeUsageDataVersion(%q).IsDevelopment = %v, want %v", tc.input, got.IsDevelopment, tc.wantDevelopment) + } + if got.IsPublishedRelease != tc.wantPublished { + t.Fatalf("DescribeUsageDataVersion(%q).IsPublishedRelease = %v, want %v", tc.input, got.IsPublishedRelease, tc.wantPublished) + } + }) + } +} + func TestVersionCompare(t *testing.T) { t.Parallel() diff --git a/pkg/server/server.go b/pkg/server/server.go index 71c74f3b1c..d6d9b83cdc 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -173,8 +173,8 @@ func Run(ctx context.Context, version string) error { log.Warn().Err(err).Msg("Failed to ensure default organization owner membership") } - // Local upgrade metrics must be durable and tenant-aware (P0-6). - // Renamed from conversion.db -> upgrade_metrics.db to reduce "marketing telemetry" optics. + // Local-only upgrade events must be durable and tenant-aware (P0-6). + // Renamed from conversion.db -> upgrade_metrics.db to reduce misleading telemetry optics. upgradeMetricsDB := filepath.Join(baseDataDir, "upgrade_metrics.db") legacyConversionDB := filepath.Join(baseDataDir, "conversion.db") if _, err := os.Stat(upgradeMetricsDB); os.IsNotExist(err) { @@ -184,19 +184,19 @@ func Run(ctx context.Context, version string) error { return } if renameErr := os.Rename(from, to); renameErr != nil { - log.Warn().Err(renameErr).Str("from", from).Str("to", to).Msg("Failed to migrate legacy upgrade metrics db artifact") + log.Warn().Err(renameErr).Str("from", from).Str("to", to).Msg("Failed to migrate legacy local-only upgrade events DB artifact") } } renameIfExists(legacyConversionDB, upgradeMetricsDB) renameIfExists(legacyConversionDB+"-wal", upgradeMetricsDB+"-wal") renameIfExists(legacyConversionDB+"-shm", upgradeMetricsDB+"-shm") - log.Info().Str("from", legacyConversionDB).Str("to", upgradeMetricsDB).Msg("Migrated legacy conversion db to upgrade metrics db") + log.Info().Str("from", legacyConversionDB).Str("to", upgradeMetricsDB).Msg("Migrated legacy conversion DB to local-only upgrade events DB") } } conversionStore, err := pkglicensing.NewConversionStore(upgradeMetricsDB) if err != nil { - return fmt.Errorf("failed to initialize local upgrade metrics store: %w", err) + return fmt.Errorf("failed to initialize local-only upgrade events store: %w", err) } conversionStoreClosed := false closeConversionStore := func() { @@ -205,7 +205,7 @@ func Run(ctx context.Context, version string) error { } conversionStoreClosed = true if err := conversionStore.Close(); err != nil { - log.Error().Err(err).Msg("Failed to close conversion store") + log.Error().Err(err).Msg("Failed to close local-only upgrade events store") } } defer closeConversionStore() diff --git a/scripts/telemetry_adoption_report.py b/scripts/telemetry_adoption_report.py new file mode 100644 index 0000000000..4119d98410 --- /dev/null +++ b/scripts/telemetry_adoption_report.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Summarize Pulse anonymous telemetry for operator-facing adoption reads. + +This script intentionally normalizes version strings before aggregation so +manual builds, dev builds, and accidental `v` prefixes do not pollute +published-release reporting. +""" + +from __future__ import annotations + +import argparse +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import json +import re +import sqlite3 +import subprocess +import sys +from typing import Any, Iterable +from urllib.request import Request, urlopen + + +DEFAULT_DB_PATH = "/var/lib/pulse-license/licenses.sqlite" +DEFAULT_GITHUB_REPO = "rcourtman/Pulse" +GIT_DESCRIBE_RE = re.compile( + r"^(?P\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?)-(?P\d+)-g(?P[0-9a-fA-F]+)(?P-dirty)?$" +) +SEMVER_RE = re.compile( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P[^+]+))?(?:\+(?P.+))?$" +) +TOKEN_RE = re.compile(r"[^0-9A-Za-z.-]+") + + +@dataclass(frozen=True) +class ClassifiedVersion: + raw_version: str + version: str + channel: str + build: str + is_development: bool + is_published_release: bool + + +def normalize_reported_version(raw: str) -> str: + value = raw.strip() + if value.startswith("v"): + value = value[1:] + if not value: + return "0.0.0-dev" + + match = GIT_DESCRIBE_RE.match(value) + if match: + build = f"git.{match.group('count')}.g{match.group('sha').lower()}" + if match.group("dirty"): + build += ".dirty" + return f"{match.group('base')}+{build}" + + if SEMVER_RE.match(value): + return value + + sanitized = TOKEN_RE.sub("-", value).strip("-.").lower() + if not sanitized: + sanitized = "dev" + return f"0.0.0-{sanitized}" + + +def parse_semver(version: str) -> dict[str, str] | None: + match = SEMVER_RE.match(version) + if not match: + return None + return { + "prerelease": match.group("prerelease") or "", + "build": match.group("build") or "", + } + + +def version_channel(version: str) -> str: + parsed = parse_semver(version) + if parsed is None: + return "unknown" + prerelease = parsed["prerelease"].lower() + build = parsed["build"].lower() + if build: + return "dev" + if prerelease.startswith("rc."): + return "rc" + if prerelease == "dev" or prerelease.startswith("dev."): + return "dev" + if prerelease: + return "prerelease" + return "stable" + + +def classify_reported_version(raw: str, published_versions: set[str]) -> ClassifiedVersion: + normalized = normalize_reported_version(raw) + parsed = parse_semver(normalized) or {"build": ""} + channel = version_channel(normalized) + published_candidate = channel in {"stable", "rc"} and not parsed["build"] + is_published_release = normalized in published_versions if published_versions else published_candidate + return ClassifiedVersion( + raw_version=raw.strip(), + version=normalized, + channel=channel, + build=parsed["build"], + is_development=channel == "dev", + is_published_release=is_published_release, + ) + + +def parse_received_at(raw: str) -> datetime: + return datetime.strptime(raw, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + + +def fetch_published_versions(repo: str) -> set[str]: + versions: set[str] = set() + page = 1 + while True: + request = Request( + f"https://api.github.com/repos/{repo}/releases?per_page=100&page={page}", + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "pulse-telemetry-adoption-report", + }, + ) + with urlopen(request, timeout=15) as response: + payload = json.loads(response.read().decode("utf-8")) + if not payload: + break + for release in payload: + if release.get("draft"): + continue + tag = str(release.get("tag_name", "")).strip() + if tag.startswith("v"): + tag = tag[1:] + if tag: + versions.add(tag) + page += 1 + return versions + + +def fetch_rows_local(db_path: str, since_days: int) -> dict[str, Any]: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + db_stats = dict( + conn.execute( + """ + SELECT + MAX(received_at) AS latest_ping, + COUNT(*) AS total_rows, + COUNT(DISTINCT install_id) AS total_distinct_installs + FROM telemetry_pings + """ + ).fetchone() + ) + rows = [ + dict(row) + for row in conn.execute( + """ + SELECT install_id, version, platform, received_at, event + FROM telemetry_pings + WHERE julianday(received_at) >= julianday('now') - ? + ORDER BY received_at DESC + """, + (since_days,), + ).fetchall() + ] + return {"db_stats": db_stats, "rows": rows} + finally: + conn.close() + + +def fetch_rows_remote(ssh_host: str, db_path: str, since_days: int) -> dict[str, Any]: + remote_script = """ +import json +import sqlite3 +import sys + +db_path = sys.argv[1] +since_days = int(sys.argv[2]) +conn = sqlite3.connect(db_path) +conn.row_factory = sqlite3.Row +db_stats_sql = ( + "SELECT MAX(received_at) AS latest_ping, " + "COUNT(*) AS total_rows, " + "COUNT(DISTINCT install_id) AS total_distinct_installs " + "FROM telemetry_pings" +) +rows_sql = ( + "SELECT install_id, version, platform, received_at, event " + "FROM telemetry_pings " + "WHERE julianday(received_at) >= julianday('now') - ? " + "ORDER BY received_at DESC" +) +try: + db_stats = dict(conn.execute(db_stats_sql).fetchone()) + rows = [ + dict(row) + for row in conn.execute(rows_sql, (since_days,)).fetchall() + ] + print(json.dumps({"db_stats": db_stats, "rows": rows})) +finally: + conn.close() +""" + result = subprocess.run( + ["ssh", ssh_host, "python3", "-", db_path, str(since_days)], + input=remote_script, + text=True, + capture_output=True, + check=True, + ) + return json.loads(result.stdout) + + +def summarize_rows( + db_stats: dict[str, Any], + rows: Iterable[dict[str, Any]], + published_versions: set[str], +) -> dict[str, Any]: + latest_by_install: dict[str, dict[str, Any]] = {} + for row in rows: + install_id = str(row["install_id"]) + existing = latest_by_install.get(install_id) + if existing is None or str(row["received_at"]) > str(existing["received_at"]): + latest_by_install[install_id] = row + + now = datetime.now(timezone.utc) + active_24h = 0 + active_72h = 0 + platform_split = Counter() + version_split = Counter() + published_split = Counter() + non_release_split = Counter() + + for row in latest_by_install.values(): + received_at = parse_received_at(str(row["received_at"])) + age = now - received_at + if age <= timedelta(hours=24): + active_24h += 1 + if age <= timedelta(hours=72): + active_72h += 1 + platform = str(row.get("platform") or "unknown").strip() or "unknown" + platform_split[platform] += 1 + identity = classify_reported_version(str(row.get("version") or ""), published_versions) + version_split[identity.version] += 1 + target = published_split if identity.is_published_release else non_release_split + target[identity.version] += 1 + + return { + "db_stats": db_stats, + "active_latest": { + "active_24h": active_24h, + "active_72h": active_72h, + }, + "latest_version_split_72h": [ + {"version": version, "installs": installs} + for version, installs in sorted(version_split.items(), key=lambda item: (-item[1], item[0])) + ], + "published_version_split_72h": [ + {"version": version, "installs": installs} + for version, installs in sorted(published_split.items(), key=lambda item: (-item[1], item[0])) + ], + "non_release_version_split_72h": [ + {"version": version, "installs": installs} + for version, installs in sorted(non_release_split.items(), key=lambda item: (-item[1], item[0])) + ], + "latest_platform_split_72h": [ + {"platform": platform, "installs": installs} + for platform, installs in sorted(platform_split.items(), key=lambda item: (-item[1], item[0])) + ], + } + + +def format_text(summary: dict[str, Any], repo: str, since_days: int) -> str: + lines = [ + "Pulse telemetry adoption report", + f"source window: last {since_days} day(s)", + f"published release validation: {repo}", + f"latest ping: {summary['db_stats'].get('latest_ping') or 'unknown'}", + f"total rows: {summary['db_stats'].get('total_rows', 0)}", + f"total distinct installs: {summary['db_stats'].get('total_distinct_installs', 0)}", + f"active installs (24h): {summary['active_latest']['active_24h']}", + f"active installs (72h): {summary['active_latest']['active_72h']}", + "", + "Published versions (latest install state in last 72h):", + ] + published = summary["published_version_split_72h"] + if published: + lines.extend(f"- {entry['version']}: {entry['installs']}" for entry in published) + else: + lines.append("- none") + lines.append("") + lines.append("Non-release or unpublished versions (latest install state in last 72h):") + non_release = summary["non_release_version_split_72h"] + if non_release: + lines.extend(f"- {entry['version']}: {entry['installs']}" for entry in non_release) + else: + lines.append("- none") + lines.append("") + lines.append("Platforms (latest install state in last 72h):") + platform_split = summary["latest_platform_split_72h"] + if platform_split: + lines.extend(f"- {entry['platform']}: {entry['installs']}" for entry in platform_split) + else: + lines.append("- none") + return "\n".join(lines) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db-path", default=DEFAULT_DB_PATH, help="path to licenses.sqlite") + parser.add_argument("--ssh-host", help="optional SSH host to query remotely, e.g. root@pulse-license") + parser.add_argument("--since-days", type=int, default=7, help="history window to inspect") + parser.add_argument( + "--github-repo", + default=DEFAULT_GITHUB_REPO, + help="GitHub repo used to validate actually published release tags", + ) + parser.add_argument( + "--format", + choices=("text", "json"), + default="text", + help="output format", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + if args.since_days < 3: + raise SystemExit("--since-days must be at least 3 so the 72h view is meaningful") + + published_versions = fetch_published_versions(args.github_repo) + source = ( + fetch_rows_remote(args.ssh_host, args.db_path, args.since_days) + if args.ssh_host + else fetch_rows_local(args.db_path, args.since_days) + ) + summary = summarize_rows(source["db_stats"], source["rows"], published_versions) + + if args.format == "json": + print(json.dumps(summary, indent=2, sort_keys=True)) + else: + print(format_text(summary, args.github_repo, args.since_days)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_telemetry_adoption_report.py b/scripts/tests/test_telemetry_adoption_report.py new file mode 100644 index 0000000000..091c4e5154 --- /dev/null +++ b/scripts/tests/test_telemetry_adoption_report.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Unit tests for telemetry_adoption_report.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +import sys +import unittest + + +SCRIPT_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPT_DIR)) + +import telemetry_adoption_report as report + + +class TelemetryAdoptionReportTest(unittest.TestCase): + def test_normalize_reported_version_strips_v_prefix(self) -> None: + self.assertEqual(report.normalize_reported_version("v6.0.0-rc.1"), "6.0.0-rc.1") + + def test_normalize_reported_version_converts_git_describe(self) -> None: + self.assertEqual( + report.normalize_reported_version("v6.0.0-rc.1-45-gABCDEF"), + "6.0.0-rc.1+git.45.gabcdef", + ) + + def test_classify_reported_version_requires_real_published_tag(self) -> None: + identity = report.classify_reported_version( + "v6.0.0-rc.2", + published_versions={"6.0.0-rc.1"}, + ) + self.assertEqual(identity.version, "6.0.0-rc.2") + self.assertEqual(identity.channel, "rc") + self.assertFalse(identity.is_published_release) + + def test_summarize_rows_uses_latest_install_state_and_splits_release_validation(self) -> None: + now = datetime.now(timezone.utc).replace(microsecond=0) + rows = [ + { + "install_id": "install-a", + "version": "v6.0.0-rc.1", + "platform": "binary", + "received_at": (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S"), + "event": "heartbeat", + }, + { + "install_id": "install-b", + "version": "v6.0.0-rc.2", + "platform": "docker", + "received_at": (now - timedelta(hours=5)).strftime("%Y-%m-%d %H:%M:%S"), + "event": "heartbeat", + }, + { + "install_id": "install-b", + "version": "v6.0.0-rc.1", + "platform": "docker", + "received_at": (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S"), + "event": "startup", + }, + { + "install_id": "install-c", + "version": "feature/new-metric", + "platform": "binary", + "received_at": (now - timedelta(days=2, hours=1)).strftime("%Y-%m-%d %H:%M:%S"), + "event": "heartbeat", + }, + ] + + summary = report.summarize_rows( + { + "latest_ping": rows[0]["received_at"], + "total_rows": 4, + "total_distinct_installs": 3, + }, + rows, + published_versions={"6.0.0-rc.1"}, + ) + + self.assertEqual(summary["active_latest"]["active_24h"], 2) + self.assertEqual(summary["active_latest"]["active_72h"], 3) + self.assertEqual( + summary["published_version_split_72h"], + [{"version": "6.0.0-rc.1", "installs": 1}], + ) + self.assertEqual( + summary["non_release_version_split_72h"], + [ + {"version": "0.0.0-feature-new-metric", "installs": 1}, + {"version": "6.0.0-rc.2", "installs": 1}, + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/tests/19-telemetry-disclosure.spec.ts b/tests/integration/tests/19-telemetry-disclosure.spec.ts index 66be8fb740..71f5c7a278 100644 --- a/tests/integration/tests/19-telemetry-disclosure.spec.ts +++ b/tests/integration/tests/19-telemetry-disclosure.spec.ts @@ -69,7 +69,7 @@ test.describe('Telemetry disclosure', () => { await page.waitForURL(/\/settings/, { timeout: 15_000 }); const telemetrySummary = page.getByText( - /rotating install ID, version, platform, resource counts, and feature flags/i, + /rotating install ID, normalized release identity, platform, resource counts, and feature flags/i, ); await expect(telemetrySummary).toBeVisible(); await expect(telemetrySummary).toContainText('Telemetry rows are retained for up to 90 days'); @@ -83,7 +83,7 @@ test.describe('Telemetry disclosure', () => { page, disclosureLink, '/docs/PRIVACY.md', - 'Pulse includes anonymous telemetry', + 'Pulse currently has two usage-data scopes', ); }); @@ -124,7 +124,9 @@ test.describe('Telemetry disclosure', () => { const dialog = page.getByRole('dialog'); await expect(dialog.getByText('Welcome to the New Navigation!')).toBeVisible(); await expect( - dialog.getByText(/rotating install ID, version, platform, resource counts, and feature flags/i), + dialog.getByText( + /rotating install ID, normalized release identity, platform, resource counts, and feature flags/i, + ), ).toBeVisible(); await expect( dialog.getByText(/IP addresses are not stored in telemetry rows/i), @@ -136,7 +138,7 @@ test.describe('Telemetry disclosure', () => { page, privacyLink, '/docs/PRIVACY.md', - 'Pulse includes anonymous telemetry', + 'Pulse currently has two usage-data scopes', ); const docsLink = dialog.getByRole('link', { name: 'Documentation' });