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:
@@ -0,0 +1 @@
|
||||
- Added anonymous usage statistics reporting the number of macOS and Windows hosts enrolled in Fleet's MDM.
|
||||
+15
-13
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user