Added anonymous usage statistics reporting the number of macOS and Windows hosts enrolled in Fleet's MDM (#48840)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #48685

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
- Tested `ShouldSendStatistics` method manually against our DB, which
covers all our changes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Added anonymous usage statistics for the number of macOS and Windows
hosts currently enrolled in Fleet’s MDM.

* **Bug Fixes**
* Improved statistics accuracy by counting only actually enrolled,
non-server macOS and Windows hosts that are using Fleet’s MDM.

* **Tests**
* Updated and extended statistics tests to verify the new enrollment
counts are computed and reported correctly.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-07-07 19:22:31 +01:00
committed by GitHub
parent 2d64dccd79
commit 4608e82481
6 changed files with 105 additions and 13 deletions
@@ -0,0 +1 @@
- Added anonymous usage statistics reporting the number of macOS and Windows hosts enrolled in Fleet's MDM.
+15 -13
View File
@@ -119,18 +119,20 @@ func TestMaybeSendStatistics(t *testing.T) {
fleet.HostsCountByOSVersion{Version: "1.2.3", NumEnrolled: 22},
},
},
HostsEnrolledByOrbitVersion: []fleet.HostsCountByOrbitVersion{},
HostsEnrolledByOsqueryVersion: []fleet.HostsCountByOsqueryVersion{},
StoredErrors: []byte(`[]`),
Organization: "Fleet",
AIFeaturesDisabled: true,
MaintenanceWindowsEnabled: true,
MaintenanceWindowsConfigured: true,
NumHostsFleetDesktopEnabled: 1984,
FleetMaintainedAppsMacOS: []string{"1password/darwin"},
FleetMaintainedAppsWindows: []string{"google-chrome/windows"},
GitOpsModeEnabled: true,
GitOpsModeExceptions: []string{"labels", "software", "secrets"},
HostsEnrolledByOrbitVersion: []fleet.HostsCountByOrbitVersion{},
HostsEnrolledByOsqueryVersion: []fleet.HostsCountByOsqueryVersion{},
StoredErrors: []byte(`[]`),
Organization: "Fleet",
AIFeaturesDisabled: true,
MaintenanceWindowsEnabled: true,
MaintenanceWindowsConfigured: true,
NumHostsFleetDesktopEnabled: 1984,
FleetMaintainedAppsMacOS: []string{"1password/darwin"},
FleetMaintainedAppsWindows: []string{"google-chrome/windows"},
GitOpsModeEnabled: true,
GitOpsModeExceptions: []string{"labels", "software", "secrets"},
NumHostsFleetMDMEnrolledMacOS: 12,
NumHostsFleetMDMEnrolledWindows: 34,
}, true, nil
}
recorded := false
@@ -149,7 +151,7 @@ func TestMaybeSendStatistics(t *testing.T) {
require.NoError(t, err)
assert.True(t, recorded)
require.True(t, cleanedup)
assert.JSONEq(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"googleWorkspaceConfigured":false,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"conditionalAccessEnabled":false,"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false,"entraConditionalAccessConfigured":false,"gitOpsModeEnabled":true,"gitOpsModeExceptions":["labels","software","secrets"]}`, requestBody)
assert.JSONEq(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"googleWorkspaceConfigured":false,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"conditionalAccessEnabled":false,"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false,"entraConditionalAccessConfigured":false,"gitOpsModeEnabled":true,"gitOpsModeExceptions":["labels","software","secrets"],"numHostsFleetMDMEnrolledMacOS":12,"numHostsFleetMDMEnrolledWindows":34}`, requestBody)
}
func TestMaybeSendStatisticsSkipsSendingIfNotNeeded(t *testing.T) {
+27
View File
@@ -6428,6 +6428,33 @@ SELECT COUNT(*) FROM hosts h LEFT JOIN host_mdm hmdm ON h.id=hmdm.host_id WHERE
return count, nil
}
// numHostsFleetMDMEnrolledDB returns the number of macOS and Windows hosts that are currently enrolled in Fleet's own
// MDM. It excludes hosts enrolled in a third-party MDM, server hosts (host_mdm.is_server = 1), and hosts that are not
// currently enrolled (host_mdm.enrolled = 0, which includes ABM-pending and unenrolled hosts).
func numHostsFleetMDMEnrolledDB(ctx context.Context, db sqlx.QueryerContext) (macOS int, windows int, err error) {
var counts struct {
MacOS int `db:"macos"`
Windows int `db:"windows"`
}
const stmt = `
SELECT
COALESCE(SUM(CASE WHEN h.platform = 'darwin' THEN 1 ELSE 0 END), 0) AS macos,
COALESCE(SUM(CASE WHEN h.platform = 'windows' THEN 1 ELSE 0 END), 0) AS windows
FROM host_mdm hm
JOIN hosts h ON h.id = hm.host_id
JOIN mobile_device_management_solutions mdms ON hm.mdm_id = mdms.id
WHERE hm.enrolled = 1
AND NOT COALESCE(hm.is_server, false)
AND mdms.name = ?
AND h.platform IN ('darwin', 'windows')
`
if err := sqlx.GetContext(ctx, db, &counts, stmt, fleet.WellKnownMDMFleet); err != nil {
return 0, 0, err
}
return counts.MacOS, counts.Windows, nil
}
func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []string) (map[string]*fleet.Host, error) {
result := map[string]*fleet.Host{}
if len(serials) == 0 {
+6
View File
@@ -114,6 +114,10 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
if err != nil {
return ctxerr.Wrap(ctx, err, "fleet maintained apps")
}
numHostsFleetMDMEnrolledMacOS, numHostsFleetMDMEnrolledWindows, err := numHostsFleetMDMEnrolledDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "number of hosts enrolled in Fleet MDM")
}
stats.NumHostsEnrolled = amountEnrolledHosts
stats.NumHostsABMPending = numHostsABMPending
@@ -169,6 +173,8 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
stats.NumQueries = numQueries
stats.FleetMaintainedAppsMacOS = fleetMaintainedAppsMacOS
stats.FleetMaintainedAppsWindows = fleetMaintainedAppsWindows
stats.NumHostsFleetMDMEnrolledMacOS = numHostsFleetMDMEnrolledMacOS
stats.NumHostsFleetMDMEnrolledWindows = numHostsFleetMDMEnrolledWindows
stats.ConditionalAccessEnabled, err = ds.conditionalAccessEnabledOnATeam(ctx, teams)
if err != nil {
+51
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"testing"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -29,6 +31,7 @@ func TestStatistics(t *testing.T) {
{"ConditionalAccessStatistics", testConditionalAccessStatistics},
{"FleetMaintainedAppsInUse", testFleetMaintainedAppsInUse},
{"GitOpsModeStatistics", testGitOpsModeStatistics},
{"FleetMDMEnrolled", testStatisticsFleetMDMEnrolled},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -841,3 +844,51 @@ func testGitOpsModeStatistics(t *testing.T, ds *Datastore) {
assert.False(t, stats.GitOpsModeEnabled)
assert.Equal(t, []string{}, stats.GitOpsModeExceptions)
}
func testStatisticsFleetMDMEnrolled(t *testing.T, ds *Datastore) {
ctx := t.Context()
// With no hosts, both counts are zero (not null/missing).
macOS, windows, err := numHostsFleetMDMEnrolledDB(ctx, ds.reader(ctx))
require.NoError(t, err)
assert.Zero(t, macOS)
assert.Zero(t, windows)
// Each host exercises one branch of the query; only enrolled, non-server, Fleet-MDM darwin/windows hosts are counted.
cases := []struct {
name string
platform string
isServer bool
enrolled bool
installedFromDep bool
mdmName string // "" means no MDM data at all
}{
{"macOS Fleet MDM", "darwin", false, true, false, fleet.WellKnownMDMFleet}, // counted (macOS)
{"windows Fleet MDM", "windows", false, true, false, fleet.WellKnownMDMFleet}, // counted (Windows)
{"macOS third-party MDM", "darwin", false, true, false, fleet.WellKnownMDMIntune}, // excluded: not Fleet
// ABM pending: DEP-assigned (installed_from_dep=1) but not yet enrolled (enrolled=0).
{"macOS ABM pending", "darwin", false, false, true, fleet.WellKnownMDMFleet}, // excluded: not enrolled
{"windows server host", "windows", true, true, false, fleet.WellKnownMDMFleet}, // excluded: is_server
{"iOS Fleet MDM", "ios", false, true, false, fleet.WellKnownMDMFleet}, // excluded: not macOS/Windows
{"macOS no MDM", "darwin", false, false, false, ""}, // excluded: no MDM data
}
for i, c := range cases {
key := fmt.Sprintf("mdm-stats-%d", i)
h := test.NewHost(t, ds, key, "", key, key, time.Now(), test.WithPlatform(c.platform))
if c.mdmName != "" {
require.NoError(t, ds.SetOrUpdateMDMData(ctx, h.ID, c.isServer, c.enrolled, "https://fleet.example.com", c.installedFromDep, c.mdmName, "", false), c.name)
}
}
// The counts flow through the full ShouldSendStatistics payload: 1 macOS and 1 Windows Fleet-MDM host.
eh := ctxerr.MockHandler{}
eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) { return nil, nil }
statsCtx := ctxerr.NewContext(ctx, eh)
premiumLicense := &fleet.LicenseInfo{Tier: fleet.TierPremium, Organization: "Fleet"}
fleetConfig := config.FleetConfig{Osquery: config.OsqueryConfig{DetailUpdateInterval: 1 * time.Hour}}
stats, shouldSend, err := ds.ShouldSendStatistics(license.NewContext(statsCtx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.Equal(t, 1, stats.NumHostsFleetMDMEnrolledMacOS)
assert.Equal(t, 1, stats.NumHostsFleetMDMEnrolledWindows)
}
+5
View File
@@ -82,6 +82,11 @@ type StatisticsPayload struct {
// GitOpsModeExceptions lists the configured GitOps mode exceptions (e.g. "labels", "software", "secrets").
// Exceptions are persisted independently of GitOpsModeEnabled.
GitOpsModeExceptions []string `json:"gitOpsModeExceptions"`
// NumHostsFleetMDMEnrolledMacOS is the number of macOS hosts actually enrolled in Fleet's own MDM
NumHostsFleetMDMEnrolledMacOS int `json:"numHostsFleetMDMEnrolledMacOS"`
// NumHostsFleetMDMEnrolledWindows is the number of Windows hosts actually enrolled in Fleet's own MDM
NumHostsFleetMDMEnrolledWindows int `json:"numHostsFleetMDMEnrolledWindows"`
}
type HostsCountByOrbitVersion struct {