Expand anonymous telemetry adoption coverage

This commit is contained in:
rcourtman 2026-05-28 11:39:30 +01:00
parent cd278bf5e2
commit a84867c044
18 changed files with 554 additions and 49 deletions

View file

@ -16,7 +16,7 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces.
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.
No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, or personally identifiable information is ever sent. See the full field list below.
#### How to disable
@ -51,9 +51,29 @@ Every field is listed below with the reason it exists — nothing else leaves yo
| PMG instances | `0` | Understand Proxmox Mail Gateway adoption in aggregate |
| VMs | `25` | Understand approximate infrastructure scale in aggregate |
| Containers | `12` | Understand approximate LXC usage in aggregate |
| Agent hosts | `4` | Understand Pulse Agent adoption for node-local telemetry in aggregate |
| Docker hosts | `2` | Understand Docker monitoring adoption in aggregate |
| Docker containers | `18` | Understand Docker / Podman workload scale in aggregate |
| Kubernetes clusters | `0` | Understand Kubernetes monitoring adoption in aggregate |
| Kubernetes nodes | `3` | Understand Kubernetes node scale in aggregate |
| Kubernetes pods | `42` | Understand Kubernetes workload scale in aggregate |
| Kubernetes deployments | `8` | Understand Kubernetes deployment adoption in aggregate |
| Storage pools | `6` | Understand storage monitoring adoption in aggregate |
| Physical disks | `24` | Understand disk-health monitoring adoption in aggregate |
| Ceph clusters | `1` | Understand Ceph monitoring adoption in aggregate |
| Network shares | `5` | Understand NAS/share monitoring adoption in aggregate |
| TrueNAS systems | `1` | Understand TrueNAS integration adoption in aggregate |
| TrueNAS VMs | `2` | Understand TrueNAS VM visibility adoption in aggregate |
| TrueNAS apps | `7` | Understand TrueNAS app visibility adoption in aggregate |
| VMware hosts | `3` | Understand VMware vSphere host monitoring adoption in aggregate |
| VMware VMs | `35` | Understand VMware vSphere VM monitoring adoption in aggregate |
| VMware datastores | `4` | Understand VMware datastore visibility adoption in aggregate |
| Availability targets | `9` | Understand agentless availability-check adoption in aggregate |
| AI enabled | `true`/`false` | See whether AI features are actually used before expanding or removing them |
| Patrol enabled | `true`/`false` | See whether proactive AI health patrol is used |
| Discovery enabled | `true`/`false` | See whether network or AI-assisted discovery is used |
| Notifications enabled | `true`/`false` | See whether alert notification delivery is configured |
| AI actions enabled | `true`/`false` | See whether AI control tools are enabled without sending action history or command content |
| Active alerts | `4` | Understand how noisy or quiet installations are in aggregate |
| Relay enabled | `true`/`false` | See whether remote-access features are being used |
| SSO enabled | `true`/`false` | See whether single-sign-on support is being used |

View file

@ -2024,6 +2024,12 @@ 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 preview contract now includes the complete anonymous telemetry
payload shape, including aggregate self-hosted adoption counters for monitored
platforms, workloads, storage, and availability targets plus coarse feature
booleans. Browser callers may display or copy the exact payload, but they must
not derive hostnames, infrastructure identifiers, prompt/chat content, license
tiers, or API-token counts from it.
That same browser-transport contract now tolerates sparse preview
payloads without changing the runtime truth. Patrol transport may omit
`finding_ids`, and infrastructure removal previews may stage optimistic rows

View file

@ -235,6 +235,11 @@ columns.
2. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `security-privacy`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary.
3. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `security-privacy`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary.
4. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `security-privacy`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary.
The panel owns compact settings-shell framing for anonymous telemetry, but
its vocabulary must stay aligned with `security-privacy`: aggregate
self-hosted adoption counts and coarse feature flags may be named, while
hostnames, credentials, infrastructure identifiers, prompts, chat messages,
and personal information must stay explicitly excluded.
5. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `security-privacy`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
6. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `security-privacy`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
7. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary.
@ -3279,6 +3284,12 @@ That shared shell boundary now also covers version-matched docs-link framing:
customer-facing privacy disclosures in shared settings surfaces must route
through `frontend-modern/src/utils/docsLinks.ts` rather than panel-local
external URLs.
That same shared-shell framing also covers the concise telemetry summary in
General settings. The shell may present the privacy contract in compact product
copy, but the vocabulary for anonymous outbound telemetry must stay aligned
with `security-privacy`: aggregate self-hosted adoption counts and coarse
feature flags are allowed, while hostnames, credentials, infrastructure
identifiers, prompts, chat messages, and personal information are not.
That same docs-link boundary also governs local legal docs surfaced from the
settings shell: shared settings surfaces such as
`AIRuntimeControlsSection.tsx` must route Terms-of-Service links through the

View file

@ -778,6 +778,13 @@ Install-wide telemetry counts are also monitoring-owned now. Any telemetry or
reporting surface that claims installation totals must aggregate across the
provisioned tenant set through the reloadable multi-tenant monitor boundary,
not by reading `GetMonitor()`'s default-org compatibility shim.
Those install-wide counts are now the canonical aggregate adoption signal for
anonymous telemetry: monitoring owns the source counts for agent hosts, Docker
and Kubernetes workloads, storage pools and physical disks, Ceph, network
shares, TrueNAS systems/VMs/apps, VMware hosts/VMs/datastores, availability
targets, and active alerts. Telemetry callers may consume those coarse totals,
but they must not bypass monitoring to read provider-local identifiers or
tenant-local resource names.
Consumer packages already use `ReadState`, but the monitoring core still has
dual truth between unified resources and `StateSnapshot`. This is the main

View file

@ -362,6 +362,13 @@ server-side telemetry rows may preserve the canonical normalized version
identity plus those same coarse booleans, but they must not retain legacy
exact commercial tier or exact API-token count fields as first-class analytics
dimensions just because older clients once sent them.
That same anonymous telemetry floor now also permits only privacy-safe
aggregate self-hosted adoption counters: counts of monitored platforms,
workloads, storage resources, physical disks, Ceph clusters, network shares,
TrueNAS and VMware resource categories, availability targets, and active
alerts. Those counts may describe scale and feature adoption, but they must not
include hostnames, resource IDs, infrastructure identifiers, credentials,
prompts, chat messages, or personal information.
That same anonymous telemetry contract also treats `install_id` as a rotating
pseudonymous identifier, not a lifetime install handle. The runtime may keep a
local rotating UUID so startup and heartbeat pings can still represent an

View file

@ -16,7 +16,7 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces.
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.
No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, or personally identifiable information is ever sent. See the full field list below.
#### How to disable
@ -51,9 +51,29 @@ Every field is listed below with the reason it exists — nothing else leaves yo
| PMG instances | `0` | Understand Proxmox Mail Gateway adoption in aggregate |
| VMs | `25` | Understand approximate infrastructure scale in aggregate |
| Containers | `12` | Understand approximate LXC usage in aggregate |
| Agent hosts | `4` | Understand Pulse Agent adoption for node-local telemetry in aggregate |
| Docker hosts | `2` | Understand Docker monitoring adoption in aggregate |
| Docker containers | `18` | Understand Docker / Podman workload scale in aggregate |
| Kubernetes clusters | `0` | Understand Kubernetes monitoring adoption in aggregate |
| Kubernetes nodes | `3` | Understand Kubernetes node scale in aggregate |
| Kubernetes pods | `42` | Understand Kubernetes workload scale in aggregate |
| Kubernetes deployments | `8` | Understand Kubernetes deployment adoption in aggregate |
| Storage pools | `6` | Understand storage monitoring adoption in aggregate |
| Physical disks | `24` | Understand disk-health monitoring adoption in aggregate |
| Ceph clusters | `1` | Understand Ceph monitoring adoption in aggregate |
| Network shares | `5` | Understand NAS/share monitoring adoption in aggregate |
| TrueNAS systems | `1` | Understand TrueNAS integration adoption in aggregate |
| TrueNAS VMs | `2` | Understand TrueNAS VM visibility adoption in aggregate |
| TrueNAS apps | `7` | Understand TrueNAS app visibility adoption in aggregate |
| VMware hosts | `3` | Understand VMware vSphere host monitoring adoption in aggregate |
| VMware VMs | `35` | Understand VMware vSphere VM monitoring adoption in aggregate |
| VMware datastores | `4` | Understand VMware datastore visibility adoption in aggregate |
| Availability targets | `9` | Understand agentless availability-check adoption in aggregate |
| AI enabled | `true`/`false` | See whether AI features are actually used before expanding or removing them |
| Patrol enabled | `true`/`false` | See whether proactive AI health patrol is used |
| Discovery enabled | `true`/`false` | See whether network or AI-assisted discovery is used |
| Notifications enabled | `true`/`false` | See whether alert notification delivery is configured |
| AI actions enabled | `true`/`false` | See whether AI control tools are enabled without sending action history or command content |
| Active alerts | `4` | Understand how noisy or quiet installations are in aggregate |
| Relay enabled | `true`/`false` | See whether remote-access features are being used |
| SSO enabled | `true`/`false` | See whether single-sign-on support is being used |

View file

@ -61,9 +61,29 @@ describe('SettingsAPI', () => {
pmg_instances: 0,
vms: 2,
containers: 3,
agent_hosts: 1,
docker_hosts: 0,
docker_containers: 0,
kubernetes_clusters: 0,
kubernetes_nodes: 0,
kubernetes_pods: 0,
kubernetes_deployments: 0,
storage_pools: 1,
physical_disks: 2,
ceph_clusters: 0,
network_shares: 0,
truenas_systems: 0,
truenas_vms: 0,
truenas_apps: 0,
vmware_hosts: 0,
vmware_vms: 0,
vmware_datastores: 0,
availability_targets: 1,
ai_enabled: false,
patrol_enabled: false,
discovery_enabled: false,
notifications_enabled: false,
ai_actions_enabled: false,
active_alerts: 0,
relay_enabled: false,
sso_enabled: false,

View file

@ -22,9 +22,29 @@ export interface TelemetryPingPreview {
pmg_instances: number;
vms: number;
containers: number;
agent_hosts: number;
docker_hosts: number;
docker_containers: number;
kubernetes_clusters: number;
kubernetes_nodes: number;
kubernetes_pods: number;
kubernetes_deployments: number;
storage_pools: number;
physical_disks: number;
ceph_clusters: number;
network_shares: number;
truenas_systems: number;
truenas_vms: number;
truenas_apps: number;
vmware_hosts: number;
vmware_vms: number;
vmware_datastores: number;
availability_targets: number;
ai_enabled: boolean;
patrol_enabled: boolean;
discovery_enabled: boolean;
notifications_enabled: boolean;
ai_actions_enabled: boolean;
active_alerts: number;
relay_enabled: boolean;
sso_enabled: boolean;

View file

@ -186,9 +186,11 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
</div>
<p class="text-xs text-muted leading-relaxed">
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.{' '}
normalized release identity, runtime platform, aggregate self-hosted adoption
counts, and coarse feature flags. No hostnames, credentials, infrastructure
identifiers, prompts, chat messages, or personal information are sent. Telemetry
rows are retained for up to 90 days, and IP addresses are not stored in telemetry
rows.{' '}
<a
href={PRIVACY_DOC_URL}
target="_blank"

View file

@ -266,6 +266,18 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps telemetry disclosure aligned with the security privacy contract', () => {
expect(generalSettingsPanelSource).toContain('aggregate self-hosted adoption');
expect(generalSettingsPanelSource).toContain('counts, and coarse feature flags');
expect(generalSettingsPanelSource).toContain(
'identifiers, prompts, chat messages, or personal information are sent.',
);
expect(generalSettingsPanelSource).toContain('Telemetry payload preview');
expect(generalSettingsPanelSource).toContain('Reset ID');
expect(generalSettingsPanelSource).not.toContain('license_tier');
expect(generalSettingsPanelSource).not.toContain('api_tokens');
});
it('keeps panel-owned gated settings routes separate from sidebar visibility', () => {
expect(settingsNavVisibilitySource).toContain('const PANEL_OWNED_FEATURE_GATE_TABS');
expect(settingsNavVisibilitySource).toContain('shouldBlockSettingsRouteItem');

View file

@ -318,9 +318,29 @@ describe('useSystemSettingsState', () => {
pmg_instances: 0,
vms: 2,
containers: 3,
agent_hosts: 1,
docker_hosts: 0,
docker_containers: 0,
kubernetes_clusters: 0,
kubernetes_nodes: 0,
kubernetes_pods: 0,
kubernetes_deployments: 0,
storage_pools: 1,
physical_disks: 2,
ceph_clusters: 0,
network_shares: 0,
truenas_systems: 0,
truenas_vms: 0,
truenas_apps: 0,
vmware_hosts: 0,
vmware_vms: 0,
vmware_datastores: 0,
availability_targets: 1,
ai_enabled: false,
patrol_enabled: false,
discovery_enabled: false,
notifications_enabled: false,
ai_actions_enabled: false,
active_alerts: 0,
relay_enabled: false,
sso_enabled: false,
@ -359,9 +379,29 @@ describe('useSystemSettingsState', () => {
pmg_instances: 0,
vms: 2,
containers: 3,
agent_hosts: 1,
docker_hosts: 0,
docker_containers: 0,
kubernetes_clusters: 0,
kubernetes_nodes: 0,
kubernetes_pods: 0,
kubernetes_deployments: 0,
storage_pools: 1,
physical_disks: 2,
ceph_clusters: 0,
network_shares: 0,
truenas_systems: 0,
truenas_vms: 0,
truenas_apps: 0,
vmware_hosts: 0,
vmware_vms: 0,
vmware_datastores: 0,
availability_targets: 1,
ai_enabled: false,
patrol_enabled: false,
discovery_enabled: false,
notifications_enabled: false,
ai_actions_enabled: false,
active_alerts: 0,
relay_enabled: false,
sso_enabled: false,
@ -405,9 +445,29 @@ describe('useSystemSettingsState', () => {
pmg_instances: 0,
vms: 0,
containers: 0,
agent_hosts: 0,
docker_hosts: 0,
docker_containers: 0,
kubernetes_clusters: 0,
kubernetes_nodes: 0,
kubernetes_pods: 0,
kubernetes_deployments: 0,
storage_pools: 0,
physical_disks: 0,
ceph_clusters: 0,
network_shares: 0,
truenas_systems: 0,
truenas_vms: 0,
truenas_apps: 0,
vmware_hosts: 0,
vmware_vms: 0,
vmware_datastores: 0,
availability_targets: 0,
ai_enabled: false,
patrol_enabled: false,
discovery_enabled: false,
notifications_enabled: false,
ai_actions_enabled: false,
active_alerts: 0,
relay_enabled: false,
sso_enabled: false,

View file

@ -514,6 +514,31 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) {
}
}
func TestInstallTelemetrySnapshotCountsStayOnMonitoringBoundary(t *testing.T) {
data, err := os.ReadFile("reload.go")
if err != nil {
t.Fatalf("failed to read reload.go: %v", err)
}
source := string(data)
for _, snippet := range []string{
"AgentHosts int",
"DockerContainers int",
"KubernetesPods int",
"TrueNASSystems int",
"VMwareDatastores int",
"AvailabilityTargets int",
"resources, _ := monitor.UnifiedResourceSnapshot()",
"accumulateInstallSnapshotUnifiedResourceCounts(counts, resources)",
"case unifiedresources.ResourceTypeNetworkShare:",
"resource.Availability != nil && resource.Availability.Enabled",
} {
if !strings.Contains(source, snippet) {
t.Fatalf("reload.go must contain %q", snippet)
}
}
}
func TestMonitoredSystemUsageStaysInventoryOnly(t *testing.T) {
data, err := os.ReadFile("monitored_system_usage.go")
if err != nil {

View file

@ -8,6 +8,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rs/zerolog/log"
)
@ -15,14 +16,30 @@ import (
// InstallSnapshotCounts holds install-wide resource and alert counts aggregated
// across tenant monitors.
type InstallSnapshotCounts struct {
PVENodes int
PBSInstances int
PMGInstances int
VMs int
Containers int
DockerHosts int
KubernetesClusters int
ActiveAlerts int
PVENodes int
PBSInstances int
PMGInstances int
VMs int
Containers int
AgentHosts int
DockerHosts int
DockerContainers int
KubernetesClusters int
KubernetesNodes int
KubernetesPods int
KubernetesDeployments int
StoragePools int
PhysicalDisks int
CephClusters int
NetworkShares int
TrueNASSystems int
TrueNASVMs int
TrueNASApps int
VMwareHosts int
VMwareVMs int
VMwareDatastores int
AvailabilityTargets int
ActiveAlerts int
}
// ReloadableMonitor wraps a Monitor with reload capability
@ -254,12 +271,62 @@ func accumulateInstallSnapshotCounts(counts *InstallSnapshotCounts, monitor *Mon
counts.PMGInstances += len(readState.PMGInstances())
counts.VMs += len(readState.VMs())
counts.Containers += len(readState.Containers())
counts.AgentHosts += len(readState.Hosts())
counts.DockerHosts += len(readState.DockerHosts())
counts.DockerContainers += len(readState.DockerContainers())
counts.KubernetesClusters += len(readState.K8sClusters())
counts.KubernetesNodes += len(readState.K8sNodes())
counts.KubernetesPods += len(readState.Pods())
counts.KubernetesDeployments += len(readState.K8sDeployments())
counts.StoragePools += len(readState.StoragePools())
counts.PhysicalDisks += len(readState.PhysicalDisks())
}
resources, _ := monitor.UnifiedResourceSnapshot()
accumulateInstallSnapshotUnifiedResourceCounts(counts, resources)
counts.ActiveAlerts += len(monitor.ActiveAlertsSnapshot())
}
func accumulateInstallSnapshotUnifiedResourceCounts(counts *InstallSnapshotCounts, resources []unifiedresources.Resource) {
if counts == nil {
return
}
for _, resource := range resources {
resourceType := unifiedresources.CanonicalResourceType(resource.Type)
switch resourceType {
case unifiedresources.ResourceTypeCeph:
counts.CephClusters++
case unifiedresources.ResourceTypeNetworkShare:
counts.NetworkShares++
}
if resource.Availability != nil && resource.Availability.Enabled {
counts.AvailabilityTargets++
}
if resource.TrueNAS != nil {
switch resourceType {
case unifiedresources.ResourceTypeAgent:
counts.TrueNASSystems++
case unifiedresources.ResourceTypeVM:
counts.TrueNASVMs++
case unifiedresources.ResourceTypeAppContainer:
counts.TrueNASApps++
}
}
if resource.VMware != nil {
switch resourceType {
case unifiedresources.ResourceTypeAgent:
counts.VMwareHosts++
case unifiedresources.ResourceTypeVM:
counts.VMwareVMs++
case unifiedresources.ResourceTypeStorage:
counts.VMwareDatastores++
}
}
}
}
// Stop stops the monitor
func (rm *ReloadableMonitor) Stop() {
rm.mu.Lock()

View file

@ -7,6 +7,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -139,6 +140,32 @@ func TestReloadableMonitorAggregateInstallSnapshotCountsIncludesProvisionedTenan
assert.Equal(t, 6, counts.ActiveAlerts)
}
func TestAccumulateInstallSnapshotUnifiedResourceCountsUsesCoarseV6AdoptionSignals(t *testing.T) {
counts := InstallSnapshotCounts{}
accumulateInstallSnapshotUnifiedResourceCounts(&counts, []unifiedresources.Resource{
{Type: unifiedresources.ResourceTypeAgent, TrueNAS: &unifiedresources.TrueNASData{}},
{Type: unifiedresources.ResourceTypeVM, TrueNAS: &unifiedresources.TrueNASData{}},
{Type: unifiedresources.ResourceTypeAppContainer, TrueNAS: &unifiedresources.TrueNASData{}},
{Type: unifiedresources.ResourceTypeAgent, VMware: &unifiedresources.VMwareData{}},
{Type: unifiedresources.ResourceTypeVM, VMware: &unifiedresources.VMwareData{}},
{Type: unifiedresources.ResourceTypeStorage, VMware: &unifiedresources.VMwareData{}},
{Type: unifiedresources.ResourceTypeCeph},
{Type: unifiedresources.ResourceTypeNetworkShare},
{Type: unifiedresources.ResourceTypeNetworkEndpoint, Availability: &unifiedresources.AvailabilityData{Enabled: true}},
{Type: unifiedresources.ResourceTypeNetworkEndpoint, Availability: &unifiedresources.AvailabilityData{Enabled: false}},
})
assert.Equal(t, 1, counts.TrueNASSystems)
assert.Equal(t, 1, counts.TrueNASVMs)
assert.Equal(t, 1, counts.TrueNASApps)
assert.Equal(t, 1, counts.VMwareHosts)
assert.Equal(t, 1, counts.VMwareVMs)
assert.Equal(t, 1, counts.VMwareDatastores)
assert.Equal(t, 1, counts.CephClusters)
assert.Equal(t, 1, counts.NetworkShares)
assert.Equal(t, 1, counts.AvailabilityTargets)
}
func testTelemetryMonitor(
nodes []models.Node,
vms []models.VM,

View file

@ -15,10 +15,13 @@
// Scale (counts only, no names):
// - Number of PVE nodes, PBS instances, PMG instances
// - Number of VMs, LXC containers
// - Number of Docker hosts and Kubernetes clusters
// - Number of Pulse Agent hosts, Docker hosts/containers, and Kubernetes clusters/nodes/pods/deployments
// - Number of storage resources, physical disks, Ceph clusters, and network shares
// - Number of TrueNAS systems/VMs/apps, VMware hosts/VMs/datastores, and availability targets
//
// Feature usage (booleans and counts, no content):
// - Whether AI features are enabled
// - Whether Patrol, discovery, notifications, or AI action capability are enabled
// - Number of active alerts
// - Whether relay/remote access is enabled
// - Whether SSO/OIDC is configured
@ -110,42 +113,82 @@ type Ping struct {
Event string `json:"event"` // "startup" or "heartbeat"
// Scale (counts only — no names, IPs, or identifiers)
PVENodes int `json:"pve_nodes"`
PBSInstances int `json:"pbs_instances"`
PMGInstances int `json:"pmg_instances"`
VMs int `json:"vms"`
Containers int `json:"containers"`
DockerHosts int `json:"docker_hosts"`
KubernetesClusters int `json:"kubernetes_clusters"`
PVENodes int `json:"pve_nodes"`
PBSInstances int `json:"pbs_instances"`
PMGInstances int `json:"pmg_instances"`
VMs int `json:"vms"`
Containers int `json:"containers"`
AgentHosts int `json:"agent_hosts"`
DockerHosts int `json:"docker_hosts"`
DockerContainers int `json:"docker_containers"`
KubernetesClusters int `json:"kubernetes_clusters"`
KubernetesNodes int `json:"kubernetes_nodes"`
KubernetesPods int `json:"kubernetes_pods"`
KubernetesDeployments int `json:"kubernetes_deployments"`
StoragePools int `json:"storage_pools"`
PhysicalDisks int `json:"physical_disks"`
CephClusters int `json:"ceph_clusters"`
NetworkShares int `json:"network_shares"`
TrueNASSystems int `json:"truenas_systems"`
TrueNASVMs int `json:"truenas_vms"`
TrueNASApps int `json:"truenas_apps"`
VMwareHosts int `json:"vmware_hosts"`
VMwareVMs int `json:"vmware_vms"`
VMwareDatastores int `json:"vmware_datastores"`
AvailabilityTargets int `json:"availability_targets"`
// Feature usage (booleans and counts — no content)
AIEnabled bool `json:"ai_enabled"`
ActiveAlerts int `json:"active_alerts"`
RelayEnabled bool `json:"relay_enabled"`
SSOEnabled bool `json:"sso_enabled"`
MultiTenant bool `json:"multi_tenant"`
PaidLicense bool `json:"paid_license"`
HasAPITokens bool `json:"has_api_tokens"`
AIEnabled bool `json:"ai_enabled"`
PatrolEnabled bool `json:"patrol_enabled"`
DiscoveryEnabled bool `json:"discovery_enabled"`
NotificationsEnabled bool `json:"notifications_enabled"`
AIActionsEnabled bool `json:"ai_actions_enabled"`
ActiveAlerts int `json:"active_alerts"`
RelayEnabled bool `json:"relay_enabled"`
SSOEnabled bool `json:"sso_enabled"`
MultiTenant bool `json:"multi_tenant"`
PaidLicense bool `json:"paid_license"`
HasAPITokens bool `json:"has_api_tokens"`
}
// Snapshot holds the dynamic state gathered at ping time.
// The telemetry package calls a user-provided SnapshotFunc to populate this,
// keeping the package decoupled from monitor/config internals.
type Snapshot struct {
PVENodes int
PBSInstances int
PMGInstances int
VMs int
Containers int
DockerHosts int
KubernetesClusters int
AIEnabled bool
ActiveAlerts int
RelayEnabled bool
SSOEnabled bool
MultiTenant bool
PaidLicense bool
HasAPITokens bool
PVENodes int
PBSInstances int
PMGInstances int
VMs int
Containers int
AgentHosts int
DockerHosts int
DockerContainers int
KubernetesClusters int
KubernetesNodes int
KubernetesPods int
KubernetesDeployments int
StoragePools int
PhysicalDisks int
CephClusters int
NetworkShares int
TrueNASSystems int
TrueNASVMs int
TrueNASApps int
VMwareHosts int
VMwareVMs int
VMwareDatastores int
AvailabilityTargets int
AIEnabled bool
PatrolEnabled bool
DiscoveryEnabled bool
NotificationsEnabled bool
AIActionsEnabled bool
ActiveAlerts int
RelayEnabled bool
SSOEnabled bool
MultiTenant bool
PaidLicense bool
HasAPITokens bool
}
// SnapshotFunc returns the current state snapshot for telemetry.
@ -322,9 +365,29 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping {
ping.PMGInstances = s.PMGInstances
ping.VMs = s.VMs
ping.Containers = s.Containers
ping.AgentHosts = s.AgentHosts
ping.DockerHosts = s.DockerHosts
ping.DockerContainers = s.DockerContainers
ping.KubernetesClusters = s.KubernetesClusters
ping.KubernetesNodes = s.KubernetesNodes
ping.KubernetesPods = s.KubernetesPods
ping.KubernetesDeployments = s.KubernetesDeployments
ping.StoragePools = s.StoragePools
ping.PhysicalDisks = s.PhysicalDisks
ping.CephClusters = s.CephClusters
ping.NetworkShares = s.NetworkShares
ping.TrueNASSystems = s.TrueNASSystems
ping.TrueNASVMs = s.TrueNASVMs
ping.TrueNASApps = s.TrueNASApps
ping.VMwareHosts = s.VMwareHosts
ping.VMwareVMs = s.VMwareVMs
ping.VMwareDatastores = s.VMwareDatastores
ping.AvailabilityTargets = s.AvailabilityTargets
ping.AIEnabled = s.AIEnabled
ping.PatrolEnabled = s.PatrolEnabled
ping.DiscoveryEnabled = s.DiscoveryEnabled
ping.NotificationsEnabled = s.NotificationsEnabled
ping.AIActionsEnabled = s.AIActionsEnabled
ping.ActiveAlerts = s.ActiveAlerts
ping.RelayEnabled = s.RelayEnabled
ping.SSOEnabled = s.SSOEnabled

View file

@ -152,13 +152,26 @@ func TestApplySnapshot(t *testing.T) {
snap := func() Snapshot {
return Snapshot{
PVENodes: 3,
VMs: 10,
Containers: 5,
AIEnabled: true,
ActiveAlerts: 2,
PaidLicense: true,
HasAPITokens: true,
PVENodes: 3,
VMs: 10,
Containers: 5,
AgentHosts: 2,
DockerContainers: 12,
KubernetesPods: 18,
StoragePools: 4,
PhysicalDisks: 9,
TrueNASSystems: 1,
TrueNASApps: 3,
VMwareHosts: 2,
AvailabilityTargets: 6,
AIEnabled: true,
PatrolEnabled: true,
DiscoveryEnabled: true,
NotificationsEnabled: true,
AIActionsEnabled: true,
ActiveAlerts: 2,
PaidLicense: true,
HasAPITokens: true,
}
}
@ -173,9 +186,21 @@ func TestApplySnapshot(t *testing.T) {
if ping.VMs != 10 {
t.Fatalf("VMs = %d, want 10", ping.VMs)
}
if ping.AgentHosts != 2 || ping.DockerContainers != 12 || ping.KubernetesPods != 18 {
t.Fatalf("expanded workload counts not applied: %#v", ping)
}
if ping.StoragePools != 4 || ping.PhysicalDisks != 9 {
t.Fatalf("expanded storage counts not applied: %#v", ping)
}
if ping.TrueNASSystems != 1 || ping.TrueNASApps != 3 || ping.VMwareHosts != 2 || ping.AvailabilityTargets != 6 {
t.Fatalf("expanded platform counts not applied: %#v", ping)
}
if !ping.AIEnabled {
t.Fatal("AIEnabled should be true")
}
if !ping.PatrolEnabled || !ping.DiscoveryEnabled || !ping.NotificationsEnabled || !ping.AIActionsEnabled {
t.Fatalf("expanded feature flags not applied: %#v", ping)
}
if !ping.PaidLicense {
t.Fatal("PaidLicense should be true")
}

View file

@ -459,13 +459,33 @@ func Run(ctx context.Context, version string) error {
snap.PMGInstances = counts.PMGInstances
snap.VMs = counts.VMs
snap.Containers = counts.Containers
snap.AgentHosts = counts.AgentHosts
snap.DockerHosts = counts.DockerHosts
snap.DockerContainers = counts.DockerContainers
snap.KubernetesClusters = counts.KubernetesClusters
snap.KubernetesNodes = counts.KubernetesNodes
snap.KubernetesPods = counts.KubernetesPods
snap.KubernetesDeployments = counts.KubernetesDeployments
snap.StoragePools = counts.StoragePools
snap.PhysicalDisks = counts.PhysicalDisks
snap.CephClusters = counts.CephClusters
snap.NetworkShares = counts.NetworkShares
snap.TrueNASSystems = counts.TrueNASSystems
snap.TrueNASVMs = counts.TrueNASVMs
snap.TrueNASApps = counts.TrueNASApps
snap.VMwareHosts = counts.VMwareHosts
snap.VMwareVMs = counts.VMwareVMs
snap.VMwareDatastores = counts.VMwareDatastores
snap.AvailabilityTargets = counts.AvailabilityTargets
snap.ActiveAlerts = counts.ActiveAlerts
snap.DiscoveryEnabled = currentCfg.DiscoveryEnabled
// Feature flags from persisted config (using pre-created persistence).
if aiCfg, err := telemetryPersistence.LoadAIConfig(); err == nil && aiCfg != nil {
snap.AIEnabled = aiCfg.Enabled
snap.PatrolEnabled = aiCfg.IsPatrolEnabled()
snap.DiscoveryEnabled = snap.DiscoveryEnabled || aiCfg.IsDiscoveryEnabled()
snap.AIActionsEnabled = aiCfg.IsControlEnabled()
}
if relayCfg, err := telemetryPersistence.LoadRelayConfig(); err == nil {
snap.RelayEnabled = relayCfg.Enabled
@ -475,6 +495,24 @@ func Run(ctx context.Context, version string) error {
if ssoCfg, err := telemetryPersistence.LoadSSOConfig(); err == nil && ssoCfg != nil {
snap.SSOEnabled = ssoCfg.HasEnabledProviders()
}
if emailCfg, err := telemetryPersistence.LoadEmailConfig(); err == nil && emailCfg != nil && emailCfg.Enabled {
snap.NotificationsEnabled = true
}
if !snap.NotificationsEnabled {
if appriseCfg, err := telemetryPersistence.LoadAppriseConfig(); err == nil && appriseCfg != nil && appriseCfg.Enabled {
snap.NotificationsEnabled = true
}
}
if !snap.NotificationsEnabled {
if webhooks, err := telemetryPersistence.LoadWebhooks(); err == nil {
for _, webhook := range webhooks {
if webhook.Enabled {
snap.NotificationsEnabled = true
break
}
}
}
}
// Coarse commercial posture only; telemetry does not send exact tiers.
if router != nil && router.GetLicenseHandlers() != nil {

View file

@ -28,6 +28,44 @@ DEFAULT_LATEST_INSTALL_WINDOWS = (
("72h", timedelta(hours=72)),
("7d", timedelta(days=7)),
)
ADOPTION_COUNT_FIELDS = (
("pve_nodes", "PVE nodes"),
("pbs_instances", "PBS instances"),
("pmg_instances", "PMG instances"),
("vms", "VMs"),
("containers", "LXC containers"),
("agent_hosts", "Agent hosts"),
("docker_hosts", "Docker hosts"),
("docker_containers", "Docker containers"),
("kubernetes_clusters", "Kubernetes clusters"),
("kubernetes_nodes", "Kubernetes nodes"),
("kubernetes_pods", "Kubernetes pods"),
("kubernetes_deployments", "Kubernetes deployments"),
("storage_pools", "Storage pools"),
("physical_disks", "Physical disks"),
("ceph_clusters", "Ceph clusters"),
("network_shares", "Network shares"),
("truenas_systems", "TrueNAS systems"),
("truenas_vms", "TrueNAS VMs"),
("truenas_apps", "TrueNAS apps"),
("vmware_hosts", "VMware hosts"),
("vmware_vms", "VMware VMs"),
("vmware_datastores", "VMware datastores"),
("availability_targets", "Availability targets"),
("active_alerts", "Active alerts"),
)
FEATURE_BOOL_FIELDS = (
("ai_enabled", "AI enabled"),
("patrol_enabled", "Patrol enabled"),
("discovery_enabled", "Discovery enabled"),
("notifications_enabled", "Notifications enabled"),
("ai_actions_enabled", "AI actions enabled"),
("relay_enabled", "Relay enabled"),
("sso_enabled", "SSO enabled"),
("multi_tenant", "Multi-tenant"),
("paid_license", "Paid license"),
("has_api_tokens", "Has API tokens"),
)
GIT_DESCRIBE_RE = re.compile(
r"^(?P<base>\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?)-(?P<count>\d+)-g(?P<sha>[0-9a-fA-F]+)(?P<dirty>-dirty)?$"
)
@ -130,6 +168,16 @@ def parse_optional_bool(value: Any) -> bool | None:
return None
def parse_optional_nonnegative_int(value: Any) -> int:
if value is None:
return 0
try:
parsed = int(value)
except (TypeError, ValueError):
return 0
return max(parsed, 0)
def classify_row_version(row: dict[str, Any], published_versions: set[str]) -> ClassifiedVersion:
raw_version = str(row.get("version") or "")
identity = classify_reported_version(raw_version, published_versions)
@ -321,6 +369,8 @@ def summarize_latest_install_windows(
published_split: Counter[str] = Counter()
non_release_split: Counter[str] = Counter()
platform_split: Counter[str] = Counter()
adoption_counts: Counter[str] = Counter()
feature_counts: Counter[str] = Counter()
for row in latest_by_install.values():
received_at = parse_received_at(str(row["received_at"]))
@ -332,6 +382,11 @@ def summarize_latest_install_windows(
platform_split[platform] += 1
target = published_split if identity.is_published_release else non_release_split
target[identity.version] += 1
for key, _ in ADOPTION_COUNT_FIELDS:
adoption_counts[key] += parse_optional_nonnegative_int(row.get(key))
for key, _ in FEATURE_BOOL_FIELDS:
if parse_optional_bool(row.get(key)):
feature_counts[key] += 1
summary[label] = {
"active_installs": sum(version_split.values()),
@ -339,6 +394,14 @@ def summarize_latest_install_windows(
"published_versions": counter_entries(published_split, "version"),
"non_release_versions": counter_entries(non_release_split, "version"),
"platforms": counter_entries(platform_split, "platform"),
"adoption_counts": [
{"field": key, "label": label, "total": adoption_counts[key]}
for key, label in ADOPTION_COUNT_FIELDS
],
"feature_enabled_installs": [
{"field": key, "label": label, "installs": feature_counts[key]}
for key, label in FEATURE_BOOL_FIELDS
],
}
return summary
@ -409,6 +472,18 @@ def format_text(summary: dict[str, Any], repo: str, since_days: int) -> str:
lines.extend(f" - {entry['platform']}: {entry['installs']}" for entry in window_summary["platforms"])
else:
lines.append(" - none")
lines.append("- aggregate adoption counts:")
adoption_counts = [entry for entry in window_summary["adoption_counts"] if entry["total"] > 0]
if adoption_counts:
lines.extend(f" - {entry['label']}: {entry['total']}" for entry in adoption_counts)
else:
lines.append(" - none")
lines.append("- feature-enabled installs:")
feature_counts = [entry for entry in window_summary["feature_enabled_installs"] if entry["installs"] > 0]
if feature_counts:
lines.extend(f" - {entry['label']}: {entry['installs']}" for entry in feature_counts)
else:
lines.append(" - none")
return "\n".join(lines)