Add weekly active users count in anonymous usage stats (#6317)

This commit is contained in:
Martin Angers
2022-06-22 15:20:57 -04:00
committed by GitHub
parent 3b86836c3f
commit dc9e158153
11 changed files with 142 additions and 68 deletions
@@ -0,0 +1 @@
* Added the weekly active users count to anonymous usage statistics.
+2 -1
View File
@@ -53,6 +53,7 @@ func TestMaybeSendStatistics(t *testing.T) {
VulnDetectionEnabled: true,
SystemUsersEnabled: true,
HostsStatusWebHookEnabled: true,
NumWeeklyActiveUsers: 111,
}, true, nil
}
recorded := false
@@ -64,7 +65,7 @@ func TestMaybeSendStatistics(t *testing.T) {
err := trySendStatistics(context.Background(), ds, fleet.StatisticsFrequency, ts.URL, &fleet.LicenseInfo{Tier: "premium"})
require.NoError(t, err)
assert.True(t, recorded)
assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","numHostsEnrolled":999,"numUsers":99,"numTeams":9,"numPolicies":0,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true}`, requestBody)
assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","numHostsEnrolled":999,"numUsers":99,"numTeams":9,"numPolicies":0,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"numWeeklyActiveUsers":111}`, requestBody)
}
func TestMaybeSendStatisticsSkipsSendingIfNotNeeded(t *testing.T) {
+6 -5
View File
@@ -15,15 +15,16 @@ Fleet Device Management Inc. periodically collects anonymous information about y
"anonymousIdentifier": "9pnzNmrES3mQG66UQtd29cYTiX2+fZ4CYxDvh495720=",
"fleetVersion": "x.x.x",
"licenseTier": "free",
"numHostsEnrolled": 12345,
"numUsers": 12,
"numTeams": 3,
"numPolicies": 5,
"numLabels": 20,
"numHostsEnrolled": 999,
"numUsers": 999,
"numTeams": 999,
"numPolicies": 999,
"numLabels": 999,
"softwareInventoryEnabled": true,
"vulnDetectionEnabled": true,
"systemUsersEnabled": true,
"hostStatusWebhookEnabled": true,
"numWeeklyActiveUsers": 999
}
```
@@ -70,15 +70,16 @@ export const usageStatsPreview = {
anonymousIdentifier: "9pnzNmrES3mQG66UQtd29cYTiX2+fZ4CYxDvh495720=",
fleetVersion: "x.x.x",
licenseTier: "free",
numHostsEnrolled: 12345,
numUsers: 12,
numTeams: 3,
numPolicies: 5,
numLabels: 20,
numHostsEnrolled: 999,
numUsers: 999,
numTeams: 999,
numPolicies: 999,
numLabels: 999,
softwareInventoryEnabled: true,
vulnDetectionEnabled: true,
systemUsersEnabled: true,
hostStatusWebhookEnabled: true,
numWeeklyActiveUsers: 999,
};
export default {
+66 -52
View File
@@ -18,33 +18,51 @@ type statistics struct {
}
func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Duration, license *fleet.LicenseInfo) (fleet.StatisticsPayload, bool, error) {
amountEnrolledHosts, err := amountEnrolledHostsDB(ctx, ds.writer)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "amount enrolled hosts")
}
amountUsers, err := amountUsersDB(ctx, ds.writer)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "amount users")
}
amountTeams, err := amountTeamsDB(ctx, ds.writer)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "amount teams")
}
amountPolicies, err := amountPoliciesDB(ctx, ds.writer)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "amount policies")
}
amountLabels, err := amountLabelsDB(ctx, ds.writer)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "amount labels")
}
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "statistics app config")
computeStats := func(stats *fleet.StatisticsPayload, since time.Time) error {
amountEnrolledHosts, err := amountEnrolledHostsDB(ctx, ds.writer)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount enrolled hosts")
}
amountUsers, err := amountUsersDB(ctx, ds.writer)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount users")
}
amountTeams, err := amountTeamsDB(ctx, ds.writer)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount teams")
}
amountPolicies, err := amountPoliciesDB(ctx, ds.writer)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount policies")
}
amountLabels, err := amountLabelsDB(ctx, ds.writer)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount labels")
}
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "statistics app config")
}
amountWeeklyUsers, err := amountActiveUsersSinceDB(ctx, ds.writer, since)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount active users")
}
stats.NumHostsEnrolled = amountEnrolledHosts
stats.NumUsers = amountUsers
stats.NumTeams = amountTeams
stats.NumPolicies = amountPolicies
stats.NumLabels = amountLabels
stats.SoftwareInventoryEnabled = appConfig.HostSettings.EnableSoftwareInventory
stats.VulnDetectionEnabled = appConfig.VulnerabilitySettings.DatabasesPath != ""
stats.SystemUsersEnabled = appConfig.HostSettings.EnableHostUsers
stats.HostsStatusWebHookEnabled = appConfig.WebhookSettings.HostStatusWebhook.Enable
stats.NumWeeklyActiveUsers = amountWeeklyUsers
return nil
}
dest := statistics{}
err = sqlx.GetContext(ctx, ds.writer, &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`)
err := sqlx.GetContext(ctx, ds.writer, &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`)
if err != nil {
if err == sql.ErrNoRows {
anonIdentifier, err := server.GenerateRandomText(64)
@@ -55,23 +73,22 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "insert statistics")
}
return fleet.StatisticsPayload{
AnonymousIdentifier: anonIdentifier,
FleetVersion: version.Version().Version,
LicenseTier: license.Tier,
NumHostsEnrolled: amountEnrolledHosts,
NumUsers: amountUsers,
NumTeams: amountTeams,
NumPolicies: amountPolicies,
NumLabels: amountLabels,
SoftwareInventoryEnabled: appConfig.HostSettings.EnableSoftwareInventory,
VulnDetectionEnabled: appConfig.VulnerabilitySettings.DatabasesPath != "",
SystemUsersEnabled: appConfig.HostSettings.EnableHostUsers,
HostsStatusWebHookEnabled: appConfig.WebhookSettings.HostStatusWebhook.Enable,
}, true, nil
// compute active weekly users since now - frequency
stats := fleet.StatisticsPayload{
AnonymousIdentifier: anonIdentifier,
FleetVersion: version.Version().Version,
LicenseTier: license.Tier,
}
if err := computeStats(&stats, time.Now().Add(-frequency)); err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "compute statistics")
}
return stats, true, nil
}
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "get statistics")
}
lastUpdated := dest.UpdatedAt
if dest.CreatedAt.After(dest.UpdatedAt) {
lastUpdated = dest.CreatedAt
@@ -79,20 +96,17 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
if time.Now().Before(lastUpdated.Add(frequency)) {
return fleet.StatisticsPayload{}, false, nil
}
return fleet.StatisticsPayload{
AnonymousIdentifier: dest.Identifier,
FleetVersion: version.Version().Version,
LicenseTier: license.Tier,
NumHostsEnrolled: amountEnrolledHosts,
NumUsers: amountUsers,
NumTeams: amountTeams,
NumPolicies: amountPolicies,
NumLabels: amountLabels,
SoftwareInventoryEnabled: appConfig.HostSettings.EnableSoftwareInventory,
VulnDetectionEnabled: appConfig.VulnerabilitySettings.DatabasesPath != "",
SystemUsersEnabled: appConfig.HostSettings.EnableHostUsers,
HostsStatusWebHookEnabled: appConfig.WebhookSettings.HostStatusWebhook.Enable,
}, true, nil
stats := fleet.StatisticsPayload{
AnonymousIdentifier: dest.Identifier,
FleetVersion: version.Version().Version,
LicenseTier: license.Tier,
}
if err := computeStats(&stats, lastUpdated); err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "compute statistics")
}
return stats, true, nil
}
func (ds *Datastore) RecordStatisticsSent(ctx context.Context) error {
+39 -4
View File
@@ -44,8 +44,8 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
// Create new user for test
_, err = ds.NewUser(context.Background(), &fleet.User{
// Create two new users for test
u1, err := ds.NewUser(context.Background(), &fleet.User{
Password: []byte("foobar"),
AdminForcedPasswordReset: false,
Email: "baz@example.com",
@@ -53,6 +53,17 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
GlobalRole: ptr.String(fleet.RoleObserver),
})
require.NoError(t, err)
_, err = ds.NewUser(context.Background(), &fleet.User{
Password: []byte("foobar"),
AdminForcedPasswordReset: false,
Email: "qux@example.com",
SSOEnabled: false,
GlobalRole: ptr.String(fleet.RoleObserver),
})
require.NoError(t, err)
// Create a session for user baz, but not qux (so only 1 is active)
_, err = ds.NewSession(context.Background(), u1.ID, "session_key")
require.NoError(t, err)
// Create new team for test
_, err = ds.NewTeam(context.Background(), &fleet.Team{
@@ -95,6 +106,8 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
err = ds.SaveAppConfig(context.Background(), config)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
license := &fleet.LicenseInfo{Tier: "premium"}
// First time running, we send statistics
@@ -105,7 +118,7 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.NotEmpty(t, stats.FleetVersion)
assert.Equal(t, stats.LicenseTier, "premium")
assert.Equal(t, stats.NumHostsEnrolled, 1)
assert.Equal(t, stats.NumUsers, 1)
assert.Equal(t, stats.NumUsers, 2)
assert.Equal(t, stats.NumTeams, 1)
assert.Equal(t, stats.NumPolicies, 1)
assert.Equal(t, stats.NumLabels, 1)
@@ -113,6 +126,7 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.Equal(t, stats.SystemUsersEnabled, false)
assert.Equal(t, stats.VulnDetectionEnabled, false)
assert.Equal(t, stats.HostsStatusWebHookEnabled, true)
assert.Equal(t, stats.NumWeeklyActiveUsers, 1)
firstIdentifier := stats.AnonymousIdentifier
@@ -124,7 +138,7 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
require.NoError(t, err)
assert.False(t, shouldSend)
time.Sleep(2)
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
_, err = ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
@@ -146,4 +160,25 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.True(t, shouldSend)
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
assert.Equal(t, stats.NumHostsEnrolled, 2)
assert.Equal(t, stats.NumUsers, 2)
assert.Equal(t, stats.NumWeeklyActiveUsers, 0) // no active user since last stats were sent
// Create multiple new sessions for a single user
_, err = ds.NewSession(context.Background(), u1.ID, "session_key2")
require.NoError(t, err)
_, err = ds.NewSession(context.Background(), u1.ID, "session_key3")
require.NoError(t, err)
_, err = ds.NewSession(context.Background(), u1.ID, "session_key4")
require.NoError(t, err)
// wait a bit and resend statistics
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
stats, shouldSend, err = ds.ShouldSendStatistics(context.Background(), time.Millisecond, license)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
assert.Equal(t, stats.NumHostsEnrolled, 2)
assert.Equal(t, stats.NumUsers, 2)
assert.Equal(t, stats.NumWeeklyActiveUsers, 1)
}
+18
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -283,3 +284,20 @@ func amountUsersDB(ctx context.Context, db sqlx.QueryerContext) (int, error) {
}
return amount, nil
}
func amountActiveUsersSinceDB(ctx context.Context, db sqlx.QueryerContext, since time.Time) (int, error) {
var amount int
err := sqlx.GetContext(ctx, db, &amount, `
SELECT count(*)
FROM users u
WHERE EXISTS (
SELECT 1
FROM sessions ssn
WHERE ssn.user_id = u.id AND
ssn.accessed_at >= ?
)`, since)
if err != nil {
return 0, err
}
return amount, nil
}
+1
View File
@@ -15,6 +15,7 @@ type StatisticsPayload struct {
VulnDetectionEnabled bool `json:"vulnDetectionEnabled"`
SystemUsersEnabled bool `json:"systemUsersEnabled"`
HostsStatusWebHookEnabled bool `json:"hostsStatusWebHookEnabled"`
NumWeeklyActiveUsers int `json:"numWeeklyActiveUsers"`
}
const (
@@ -20,6 +20,7 @@ module.exports = {
vulnDetectionEnabled: { type: 'boolean', defaultsTo: false },
systemUsersEnabled: { type: 'boolean', defaultsTo: false },
hostStatusWebhookEnabled: { type: 'boolean', defaultsTo: false },
numWeeklyActiveUsers: { type: 'number', defaultsTo: 0 },
},
+1
View File
@@ -24,6 +24,7 @@ module.exports = {
vulnDetectionEnabled: { required: true, type: 'boolean' },
systemUsersEnabled: { required: true, type: 'boolean' },
hostStatusWebhookEnabled: { required: true, type: 'boolean' },
numWeeklyActiveUsers: { required: true, type: 'number' },
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
+1 -1
View File
@@ -13,7 +13,7 @@
Cloud.setup({
/* eslint-disable */
methods: {"downloadSitemap":{"verb":"GET","url":"/sitemap.xml","args":[]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostStatusWebhookEnabled"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","topic","firstName","lastName","message"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"signup":{"verb":"POST","url":"/api/v1/customers/signup","args":["emailAddress","password","organization","firstName","lastName"]},"updateProfile":{"verb":"POST","url":"/api/v1/account/update-profile","args":["firstName","lastName","organization","emailAddress"]},"updatePassword":{"verb":"POST","url":"/api/v1/account/update-password","args":["oldPassword","newPassword"]},"updateBillingCard":{"verb":"POST","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"POST","url":"/api/v1/customers/login","args":["emailAddress","password","rememberMe"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"createQuote":{"verb":"POST","url":"/api/v1/customers/create-quote","args":["numberOfHosts"]},"saveBillingInfoAndSubscribe":{"verb":"POST","url":"/api/v1/customers/save-billing-info-and-subscribe","args":["quoteId","paymentSource"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverDemoSignup":{"verb":"POST","url":"/api/v1/deliver-demo-signup","args":[]}}
methods: {"downloadSitemap":{"verb":"GET","url":"/sitemap.xml","args":[]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostStatusWebhookEnabled", "numWeeklyActiveUsers"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","topic","firstName","lastName","message"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"signup":{"verb":"POST","url":"/api/v1/customers/signup","args":["emailAddress","password","organization","firstName","lastName"]},"updateProfile":{"verb":"POST","url":"/api/v1/account/update-profile","args":["firstName","lastName","organization","emailAddress"]},"updatePassword":{"verb":"POST","url":"/api/v1/account/update-password","args":["oldPassword","newPassword"]},"updateBillingCard":{"verb":"POST","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"POST","url":"/api/v1/customers/login","args":["emailAddress","password","rememberMe"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"createQuote":{"verb":"POST","url":"/api/v1/customers/create-quote","args":["numberOfHosts"]},"saveBillingInfoAndSubscribe":{"verb":"POST","url":"/api/v1/customers/save-billing-info-and-subscribe","args":["quoteId","paymentSource"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverDemoSignup":{"verb":"POST","url":"/api/v1/deliver-demo-signup","args":[]}}
/* eslint-enable */
});