mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
coderd_insights_applications_usage_seconds summed each family's per-app minutes, so a user with VS Code and Cursor open in the same minute was counted as two VS Code minutes. GetTemplateInsightsByTemplate now takes the registry's app-to-family mapping as jsonb, groups the raw agent stats by family, and counts each minute once per user and family. - Keep the metric's four built-in series (Visual Studio Code, JetBrains, Web Terminal, SSH) and their labels; only their values change. The HELP text now says built-in apps are grouped by family. - Add AppFamilyName.DisplayName() and remove the duplicate TemplateBuiltinAppDisplayName* constants from codersdk. The template insights handler returns the same display names. - Pin how the migration 000596 rollback approximates family minutes from per-app rows. Family values can drop wherever the old per-app sum double-counted overlapping apps. No migration or storage changes.
444 lines
12 KiB
Go
444 lines
12 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
func Test_parseInsightsStartAndEndTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Logf("machine location: %s", time.Now().Location())
|
|
layout := insightsTimeLayout
|
|
now := time.Now().UTC()
|
|
t.Logf("now: %s", now)
|
|
t.Logf("now location: %s", now.Location())
|
|
y, m, d := now.Date()
|
|
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
|
t.Logf("today: %s", today)
|
|
thisHour := time.Date(y, m, d, now.Hour(), 0, 0, 0, time.UTC)
|
|
t.Logf("thisHour: %s", thisHour)
|
|
thisHourRoundUp := thisHour.Add(time.Hour)
|
|
t.Logf("thisHourRoundUp: %s", thisHourRoundUp)
|
|
|
|
helsinki, err := time.LoadLocation("Europe/Helsinki")
|
|
require.NoError(t, err)
|
|
|
|
type args struct {
|
|
startTime string
|
|
endTime string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
wantStartTime time.Time
|
|
wantEndTime time.Time
|
|
wantOk bool
|
|
}{
|
|
{
|
|
name: "Same",
|
|
args: args{
|
|
startTime: "2023-07-10T00:00:00Z",
|
|
endTime: "2023-07-10T00:00:00Z",
|
|
},
|
|
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, time.UTC),
|
|
wantEndTime: time.Date(2023, 7, 10, 0, 0, 0, 0, time.UTC),
|
|
wantOk: true,
|
|
},
|
|
{
|
|
name: "Week",
|
|
args: args{
|
|
startTime: "2023-07-10T00:00:00Z",
|
|
endTime: "2023-07-17T00:00:00Z",
|
|
},
|
|
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, time.UTC),
|
|
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, time.UTC),
|
|
wantOk: true,
|
|
},
|
|
{
|
|
name: "Today",
|
|
args: args{
|
|
startTime: today.Format(layout),
|
|
endTime: thisHour.Format(layout),
|
|
},
|
|
wantStartTime: time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC),
|
|
wantEndTime: time.Date(today.Year(), today.Month(), today.Day(), thisHour.Hour(), 0, 0, 0, time.UTC),
|
|
wantOk: true,
|
|
},
|
|
{
|
|
name: "Today with minutes and seconds",
|
|
args: args{
|
|
startTime: today.Format(layout),
|
|
endTime: thisHour.Add(time.Minute + time.Second).Format(layout),
|
|
},
|
|
wantOk: false,
|
|
},
|
|
{
|
|
name: "Today hour round up",
|
|
args: args{
|
|
startTime: today.Format(layout),
|
|
endTime: thisHourRoundUp.Format(layout),
|
|
},
|
|
wantStartTime: time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC),
|
|
wantEndTime: time.Date(thisHourRoundUp.Year(), thisHourRoundUp.Month(), thisHourRoundUp.Day(), thisHourRoundUp.Hour(), 0, 0, 0, time.UTC),
|
|
wantOk: true,
|
|
},
|
|
{
|
|
name: "Other timezone week",
|
|
args: args{
|
|
startTime: "2023-07-10T00:00:00+03:00",
|
|
endTime: "2023-07-17T00:00:00+03:00",
|
|
},
|
|
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, helsinki),
|
|
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, helsinki),
|
|
wantOk: true,
|
|
},
|
|
{
|
|
name: "Daylight savings time",
|
|
args: args{
|
|
startTime: "2023-03-26T00:00:00+02:00",
|
|
endTime: "2023-03-27T00:00:00+03:00",
|
|
},
|
|
wantStartTime: time.Date(2023, 3, 26, 0, 0, 0, 0, helsinki),
|
|
wantEndTime: time.Date(2023, 3, 27, 0, 0, 0, 0, helsinki),
|
|
wantOk: true,
|
|
},
|
|
{
|
|
name: "Bad format",
|
|
args: args{
|
|
startTime: "2023-07-10",
|
|
endTime: "2023-07-17",
|
|
},
|
|
wantOk: false,
|
|
},
|
|
{
|
|
name: "Zero time",
|
|
args: args{
|
|
startTime: (time.Time{}).Format(layout),
|
|
endTime: (time.Time{}).Format(layout),
|
|
},
|
|
wantOk: false,
|
|
},
|
|
{
|
|
name: "Time in future",
|
|
args: args{
|
|
startTime: today.AddDate(0, 0, 1).Format(layout),
|
|
endTime: today.AddDate(0, 0, 2).Format(layout),
|
|
},
|
|
wantOk: false,
|
|
},
|
|
{
|
|
name: "End before start",
|
|
args: args{
|
|
startTime: today.Format(layout),
|
|
endTime: today.AddDate(0, 0, -1).Format(layout),
|
|
},
|
|
wantOk: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Log("startTime: ", tt.args.startTime)
|
|
t.Log("endTime: ", tt.args.endTime)
|
|
if tt.wantOk {
|
|
t.Log("wantStartTime: ", tt.wantStartTime)
|
|
t.Log("wantEndTime: ", tt.wantEndTime)
|
|
}
|
|
|
|
rw := httptest.NewRecorder()
|
|
gotStartTime, gotEndTime, gotOk := parseInsightsStartAndEndTime(context.Background(), rw, now, tt.args.startTime, tt.args.endTime)
|
|
|
|
if !assert.Equal(t, tt.wantOk, gotOk) {
|
|
//nolint:bodyclose
|
|
t.Log("Status: ", rw.Result().StatusCode)
|
|
t.Log("Body: ", rw.Body.String())
|
|
return
|
|
}
|
|
// assert.Equal is unable to test time equality with different
|
|
// (but same) locations because the *time.Location names differ
|
|
// between LoadLocation and Parse, so we use assert.WithinDuration.
|
|
assert.WithinDuration(t, tt.wantStartTime, gotStartTime, 0)
|
|
assert.True(t, tt.wantStartTime.Equal(gotStartTime))
|
|
assert.WithinDuration(t, tt.wantEndTime, gotEndTime, 0)
|
|
assert.True(t, tt.wantEndTime.Equal(gotEndTime))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_parseInsightsInterval_week(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
layout := insightsTimeLayout
|
|
losAngelesLoc, err := time.LoadLocation("America/Los_Angeles") // Random location
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now().In(losAngelesLoc)
|
|
t.Logf("now: %s", now)
|
|
|
|
y, m, d := now.Date()
|
|
today := time.Date(y, m, d, 0, 0, 0, 0, losAngelesLoc)
|
|
t.Logf("today: %s", today)
|
|
|
|
thisHour := time.Date(y, m, d, now.Hour(), 0, 0, 0, losAngelesLoc)
|
|
t.Logf("thisHour: %s", thisHour)
|
|
twoHoursAgo := thisHour.Add(-2 * time.Hour)
|
|
t.Logf("twoHoursAgo: %s", twoHoursAgo)
|
|
thirteenDaysAgo := today.AddDate(0, 0, -13)
|
|
t.Logf("thirteenDaysAgo: %s", thirteenDaysAgo)
|
|
|
|
sixDaysAgo := today.AddDate(0, 0, -6)
|
|
t.Logf("sixDaysAgo: %s", sixDaysAgo)
|
|
nineDaysAgo := today.AddDate(0, 0, -9)
|
|
t.Logf("nineDaysAgo: %s", nineDaysAgo)
|
|
|
|
type args struct {
|
|
startTime string
|
|
endTime string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
wantOk bool
|
|
}{
|
|
{
|
|
name: "Two full weeks",
|
|
args: args{
|
|
startTime: "2023-08-10T00:00:00+02:00",
|
|
endTime: "2023-08-24T00:00:00+02:00",
|
|
},
|
|
wantOk: true,
|
|
},
|
|
{
|
|
name: "One full week",
|
|
args: args{
|
|
startTime: "2023-09-06T00:00:00+02:00",
|
|
endTime: "2023-09-13T00:00:00+02:00",
|
|
},
|
|
wantOk: true,
|
|
},
|
|
/* FIXME: daylight savings issue
|
|
{
|
|
name: "6 days are acceptable",
|
|
args: args{
|
|
startTime: sixDaysAgo.Format(layout),
|
|
endTime: stripTime(thisHour).Format(layout),
|
|
},
|
|
wantOk: true,
|
|
},*/
|
|
{
|
|
name: "Shorter than a full week",
|
|
args: args{
|
|
startTime: "2023-09-08T00:00:00+02:00",
|
|
endTime: "2023-09-13T00:00:00+02:00",
|
|
},
|
|
wantOk: false,
|
|
},
|
|
{
|
|
name: "9 days (7 + 2) are not acceptable",
|
|
args: args{
|
|
startTime: nineDaysAgo.Format(layout),
|
|
endTime: stripTime(thisHour).Format(layout),
|
|
},
|
|
wantOk: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Log("startTime: ", tt.args.startTime)
|
|
t.Log("endTime: ", tt.args.endTime)
|
|
|
|
rw := httptest.NewRecorder()
|
|
startTime, endTime, ok := parseInsightsStartAndEndTime(context.Background(), rw, now, tt.args.startTime, tt.args.endTime)
|
|
if !ok {
|
|
//nolint:bodyclose
|
|
t.Log("Status: ", rw.Result().StatusCode)
|
|
t.Log("Body: ", rw.Body.String())
|
|
}
|
|
require.True(t, ok, "start_time and end_time must be valid")
|
|
|
|
parsedInterval, gotOk := parseInsightsInterval(context.Background(), rw, "week", startTime, endTime)
|
|
if !assert.Equal(t, tt.wantOk, gotOk) {
|
|
//nolint:bodyclose
|
|
t.Log("Status: ", rw.Result().StatusCode)
|
|
t.Log("Body: ", rw.Body.String())
|
|
return
|
|
}
|
|
if tt.wantOk {
|
|
assert.Equal(t, codersdk.InsightsReportIntervalWeek, parsedInterval)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLastReportIntervalHasAtLeastSixDays(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
loc, err := time.LoadLocation("Europe/Warsaw")
|
|
require.NoError(t, err)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
startTime time.Time
|
|
endTime time.Time
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "perfectly full week",
|
|
startTime: time.Date(2023, time.September, 11, 12, 0, 0, 0, loc),
|
|
endTime: time.Date(2023, time.September, 18, 12, 0, 0, 0, loc),
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "exactly 6 days apart",
|
|
startTime: time.Date(2023, time.September, 11, 12, 0, 0, 0, loc),
|
|
endTime: time.Date(2023, time.September, 17, 12, 0, 0, 0, loc),
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "less than 6 days apart",
|
|
startTime: time.Date(2023, time.September, 11, 12, 0, 0, 0, time.UTC),
|
|
endTime: time.Date(2023, time.September, 17, 11, 0, 0, 0, time.UTC),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "forward DST change, 5 days and 23 hours apart",
|
|
startTime: time.Date(2023, time.March, 22, 12, 0, 0, 0, loc), // A day before DST starts
|
|
endTime: time.Date(2023, time.March, 28, 12, 0, 0, 0, loc), // Exactly 6 "days" apart
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Log("startTime: ", tc.startTime)
|
|
t.Log("endTime: ", tc.endTime)
|
|
|
|
result := lastReportIntervalHasAtLeastSixDays(tc.startTime, tc.endTime)
|
|
if result != tc.expected {
|
|
t.Errorf("Expected %v, but got %v for start time %v and end time %v", tc.expected, result, tc.startTime, tc.endTime)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// stripTime strips the time from a time.Time value, but keeps the date and TZ.
|
|
func stripTime(t time.Time) time.Time {
|
|
y, m, d := t.Date()
|
|
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
func TestConvertTemplateInsightsApps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("FoldsAppsIntoFamily", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sharedTemplateID, cursorTemplateID := uuid.New(), uuid.New()
|
|
apps, err := convertTemplateInsightsApps(database.GetTemplateInsightsRow{
|
|
SessionAppUsageSeconds: json.RawMessage(`{"vscode": 300, "cursor": 120, "jetbrains": 60}`),
|
|
SessionAppTemplateIds: json.RawMessage(`{
|
|
"vscode": ["` + sharedTemplateID.String() + `"],
|
|
"cursor": ["` + sharedTemplateID.String() + `", "` + cursorTemplateID.String() + `"]
|
|
}`),
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
vscode := appBySlug(t, apps, "vscode")
|
|
// Minutes the two share count in both, so the family adds them up.
|
|
require.EqualValues(t, 420, vscode.Seconds)
|
|
require.ElementsMatch(t, []uuid.UUID{sharedTemplateID, cursorTemplateID}, vscode.TemplateIDs)
|
|
require.EqualValues(t, 60, appBySlug(t, apps, "jetbrains").Seconds)
|
|
require.Equal(t, []uuid.UUID{}, appBySlug(t, apps, "ssh").TemplateIDs)
|
|
})
|
|
|
|
t.Run("UnknownAppIsNotABuiltin", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
apps, err := convertTemplateInsightsApps(database.GetTemplateInsightsRow{
|
|
SessionAppUsageSeconds: json.RawMessage(`{"some_new_ide": 300}`),
|
|
SessionAppTemplateIds: json.RawMessage(`{}`),
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
for _, app := range apps {
|
|
require.Zero(t, app.Seconds, "app %q", app.Slug)
|
|
}
|
|
})
|
|
|
|
t.Run("SFTPLegacy", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sftpTemplateID := uuid.New()
|
|
apps, err := convertTemplateInsightsApps(database.GetTemplateInsightsRow{
|
|
SessionAppUsageSeconds: json.RawMessage(`{"sftp": 300}`),
|
|
SessionAppTemplateIds: json.RawMessage(`{"sftp": ["` + sftpTemplateID.String() + `"]}`),
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
for _, app := range apps {
|
|
if app.Slug != "sftp" {
|
|
require.Equal(t, []uuid.UUID{}, app.TemplateIDs)
|
|
}
|
|
}
|
|
require.Contains(t, apps, codersdk.TemplateAppUsage{
|
|
// The rollup no longer produces SFTP usage, but rows migrated
|
|
// from the old sftp_mins column still report it.
|
|
TemplateIDs: []uuid.UUID{sftpTemplateID},
|
|
Type: codersdk.TemplateAppsTypeBuiltin,
|
|
DisplayName: codersdk.AppFamilySFTP.DisplayName(),
|
|
Slug: "sftp",
|
|
Icon: "/icon/terminal.svg",
|
|
Seconds: 300,
|
|
})
|
|
})
|
|
|
|
t.Run("Malformed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, usage := range map[string]database.GetTemplateInsightsRow{
|
|
"UsageSeconds": {
|
|
SessionAppUsageSeconds: json.RawMessage(`{"vscode": {}}`),
|
|
SessionAppTemplateIds: json.RawMessage(`{}`),
|
|
},
|
|
"TemplateIDs": {
|
|
SessionAppUsageSeconds: json.RawMessage(`{}`),
|
|
SessionAppTemplateIds: json.RawMessage(`{"vscode": "not-a-uuid"}`),
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
apps, err := convertTemplateInsightsApps(usage, nil)
|
|
require.ErrorContains(t, err, "decode session app")
|
|
require.Nil(t, apps)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func appBySlug(t *testing.T, apps []codersdk.TemplateAppUsage, slug string) codersdk.TemplateAppUsage {
|
|
t.Helper()
|
|
|
|
for _, app := range apps {
|
|
if app.Slug == slug {
|
|
return app
|
|
}
|
|
}
|
|
t.Fatalf("no app with slug %q", slug)
|
|
return codersdk.TemplateAppUsage{}
|
|
}
|