Skip to content

Commit 00a9b6a

Browse files
committed
add test for latencies
1 parent 2198c5f commit 00a9b6a

File tree

6 files changed

+289
-19
lines changed

6 files changed

+289
-19
lines changed

coderd/database/dbfake/dbfake.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2188,7 +2188,66 @@ func (q *FakeQuerier) GetUserLatencyInsights(ctx context.Context, arg database.G
21882188
return nil, err
21892189
}
21902190

2191-
panic("not implemented")
2191+
q.mutex.RLock()
2192+
defer q.mutex.RUnlock()
2193+
2194+
latenciesByUserID := make(map[uuid.UUID][]float64)
2195+
seenTemplatesByUserID := make(map[uuid.UUID]map[uuid.UUID]struct{})
2196+
for _, s := range q.workspaceAgentStats {
2197+
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
2198+
continue
2199+
}
2200+
if !arg.StartTime.Equal(s.CreatedAt) && !(s.CreatedAt.After(arg.StartTime) && s.CreatedAt.Before(arg.EndTime)) {
2201+
continue
2202+
}
2203+
if s.ConnectionCount == 0 {
2204+
continue
2205+
}
2206+
2207+
latenciesByUserID[s.UserID] = append(latenciesByUserID[s.UserID], s.ConnectionMedianLatencyMS)
2208+
if seenTemplatesByUserID[s.UserID] == nil {
2209+
seenTemplatesByUserID[s.UserID] = make(map[uuid.UUID]struct{})
2210+
}
2211+
seenTemplatesByUserID[s.UserID][s.TemplateID] = struct{}{}
2212+
}
2213+
2214+
tryPercentile := func(fs []float64, p float64) float64 {
2215+
if len(fs) == 0 {
2216+
return -1
2217+
}
2218+
sort.Float64s(fs)
2219+
return fs[int(float64(len(fs))*p/100)]
2220+
}
2221+
2222+
var rows []database.GetUserLatencyInsightsRow
2223+
for userID, latencies := range latenciesByUserID {
2224+
sort.Float64s(latencies)
2225+
templateSet := seenTemplatesByUserID[userID]
2226+
templateIDs := make([]uuid.UUID, 0, len(templateSet))
2227+
for templateID := range templateSet {
2228+
templateIDs = append(templateIDs, templateID)
2229+
}
2230+
slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool {
2231+
return a.String() < b.String()
2232+
})
2233+
user, err := q.getUserByIDNoLock(userID)
2234+
if err != nil {
2235+
return nil, err
2236+
}
2237+
row := database.GetUserLatencyInsightsRow{
2238+
UserID: userID,
2239+
Username: user.Username,
2240+
TemplateIDs: templateIDs,
2241+
WorkspaceConnectionLatency50: tryPercentile(latencies, 50),
2242+
WorkspaceConnectionLatency95: tryPercentile(latencies, 95),
2243+
}
2244+
rows = append(rows, row)
2245+
}
2246+
slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) bool {
2247+
return a.UserID.String() < b.UserID.String()
2248+
})
2249+
2250+
return rows, nil
21922251
}
21932252

21942253
func (q *FakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) {

coderd/database/queries/insights.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ FROM usage_by_user, unnest(template_ids) as template_id;
5656

5757
-- name: GetTemplateDailyInsights :many
5858
WITH d AS (
59+
-- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series.
5960
SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '1 day'::interval) AS d
6061
), ts AS (
6162
SELECT
6263
d::timestamptz AS from_,
63-
(d + '1 day'::interval)::timestamptz AS to_
64+
CASE WHEN (d + '1 day'::interval)::timestamptz <= @end_time::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE @end_time::timestamptz AS to_
6465
FROM d
6566
), usage_by_day AS (
6667
SELECT

coderd/insights.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,6 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
307307
// clock must be set to 00:00:00, except for "today", where end time is allowed
308308
// to provide the hour of the day (e.g. 14:00:00).
309309
func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, startTimeString, endTimeString string) (startTime, endTime time.Time, ok bool) {
310-
const insightsTimeLayout = time.RFC3339Nano
311310
now := time.Now()
312311

313312
for _, qp := range []struct {
@@ -317,14 +316,14 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s
317316
{"start_time", startTimeString, &startTime},
318317
{"end_time", endTimeString, &endTime},
319318
} {
320-
t, err := time.Parse(insightsTimeLayout, qp.value)
319+
t, err := time.Parse(codersdk.InsightsTimeLayout, qp.value)
321320
if err != nil {
322321
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
323322
Message: "Query parameter has invalid value.",
324323
Validations: []codersdk.ValidationError{
325324
{
326325
Field: qp.name,
327-
Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, insightsTimeLayout, err.Error()),
326+
Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, codersdk.InsightsTimeLayout, err.Error()),
328327
},
329328
},
330329
})
@@ -344,7 +343,8 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s
344343
return time.Time{}, time.Time{}, false
345344
}
346345

347-
if t.After(now) {
346+
// Round upwards one hour to ensure we can fetch the latest data.
347+
if t.After(now.Truncate(time.Hour).Add(time.Hour)) {
348348
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
349349
Message: "Query parameter has invalid value.",
350350
Validations: []codersdk.ValidationError{

coderd/insights_internal_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package coderd
2+
3+
import (
4+
"context"
5+
"net/http/httptest"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/coder/codersdk"
13+
)
14+
15+
func Test_parseInsightsStartAndEndTime(t *testing.T) {
16+
t.Parallel()
17+
18+
format := codersdk.InsightsTimeLayout
19+
now := time.Now().UTC()
20+
y, m, d := now.Date()
21+
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
22+
thisHour := time.Date(y, m, d, now.Hour(), 0, 0, 0, time.UTC)
23+
thisHourRoundUp := thisHour.Add(time.Hour)
24+
25+
helsinki, err := time.LoadLocation("Europe/Helsinki")
26+
require.NoError(t, err)
27+
28+
type args struct {
29+
startTime string
30+
endTime string
31+
}
32+
tests := []struct {
33+
name string
34+
args args
35+
wantStartTime time.Time
36+
wantEndTime time.Time
37+
wantOk bool
38+
}{
39+
{
40+
name: "Week",
41+
args: args{
42+
startTime: "2023-07-10T00:00:00Z",
43+
endTime: "2023-07-17T00:00:00Z",
44+
},
45+
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, time.UTC),
46+
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, time.UTC),
47+
wantOk: true,
48+
},
49+
{
50+
name: "Today",
51+
args: args{
52+
startTime: today.Format(format),
53+
endTime: thisHour.Format(format),
54+
},
55+
wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC),
56+
wantEndTime: time.Date(2023, 7, today.Day(), thisHour.Hour(), 0, 0, 0, time.UTC),
57+
wantOk: true,
58+
},
59+
{
60+
name: "Today with minutes and seconds",
61+
args: args{
62+
startTime: today.Format(format),
63+
endTime: thisHour.Add(time.Minute + time.Second).Format(format),
64+
},
65+
wantOk: false,
66+
},
67+
{
68+
name: "Today (hour round up)",
69+
args: args{
70+
startTime: today.Format(format),
71+
endTime: thisHourRoundUp.Format(format),
72+
},
73+
wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC),
74+
wantEndTime: time.Date(2023, 7, today.Day(), thisHourRoundUp.Hour(), 0, 0, 0, time.UTC),
75+
wantOk: true,
76+
},
77+
{
78+
name: "Other timezone week",
79+
args: args{
80+
startTime: "2023-07-10T00:00:00+03:00",
81+
endTime: "2023-07-17T00:00:00+03:00",
82+
},
83+
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, helsinki),
84+
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, helsinki),
85+
wantOk: true,
86+
},
87+
{
88+
name: "Bad format",
89+
args: args{
90+
startTime: "2023-07-10",
91+
endTime: "2023-07-17",
92+
},
93+
wantOk: false,
94+
},
95+
{
96+
name: "Zero time",
97+
args: args{
98+
startTime: (time.Time{}).Format(format),
99+
endTime: (time.Time{}).Format(format),
100+
},
101+
wantOk: false,
102+
},
103+
{
104+
name: "Time in future",
105+
args: args{
106+
startTime: today.AddDate(0, 0, 1).Format(format),
107+
endTime: today.AddDate(0, 0, 2).Format(format),
108+
},
109+
wantOk: false,
110+
},
111+
{
112+
name: "End before start",
113+
args: args{
114+
startTime: today.Format(format),
115+
endTime: today.AddDate(0, 0, -1).Format(format),
116+
},
117+
wantOk: false,
118+
},
119+
}
120+
for _, tt := range tests {
121+
tt := tt
122+
t.Run(tt.name, func(t *testing.T) {
123+
t.Parallel()
124+
125+
rw := httptest.NewRecorder()
126+
gotStartTime, gotEndTime, gotOk := parseInsightsStartAndEndTime(context.Background(), rw, tt.args.startTime, tt.args.endTime)
127+
128+
// assert.Equal is unable to test location equality, so we
129+
// use assert.WithinDuration.
130+
assert.WithinDuration(t, tt.wantStartTime, gotStartTime, 0)
131+
assert.True(t, tt.wantStartTime.Equal(gotStartTime))
132+
assert.WithinDuration(t, tt.wantEndTime, gotEndTime, 0)
133+
assert.True(t, tt.wantEndTime.Equal(gotEndTime))
134+
assert.Equal(t, tt.wantOk, gotOk)
135+
})
136+
}
137+
}

coderd/insights_test.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ func TestUserLatencyInsights(t *testing.T) {
112112
})
113113

114114
user := coderdtest.CreateFirstUser(t, client)
115+
_, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
115116
authToken := uuid.NewString()
116117
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
117118
Parse: echo.ParseComplete,
@@ -134,15 +135,54 @@ func TestUserLatencyInsights(t *testing.T) {
134135
defer func() {
135136
_ = agentCloser.Close()
136137
}()
137-
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
138+
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
138139

139140
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
140141
defer cancel()
141142

142-
userLatencies, err := client.UserLatencyInsights(ctx)
143+
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{
144+
Logger: logger.Named("client"),
145+
})
146+
require.NoError(t, err)
147+
defer conn.Close()
148+
149+
sshConn, err := conn.SSHClient(ctx)
143150
require.NoError(t, err)
151+
defer sshConn.Close()
144152

145-
t.Logf("%#v\n", userLatencies)
153+
// Create users that will not appear in the report.
154+
_, user3 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
155+
_, user4 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
156+
_, err = client.UpdateUserStatus(ctx, user3.Username, codersdk.UserStatusSuspended)
157+
require.NoError(t, err)
158+
err = client.DeleteUser(ctx, user4.ID)
159+
require.NoError(t, err)
160+
161+
y, m, d := time.Now().Date()
162+
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
163+
164+
_ = sshConn.Close()
165+
166+
var userLatencies codersdk.UserLatencyInsightsResponse
167+
require.Eventuallyf(t, func() bool {
168+
userLatencies, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
169+
StartTime: today,
170+
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
171+
TemplateIDs: []uuid.UUID{template.ID},
172+
})
173+
if !assert.NoError(t, err) {
174+
return false
175+
}
176+
if userLatencies.Report.Users[0].UserID == user2.ID {
177+
userLatencies.Report.Users[0], userLatencies.Report.Users[1] = userLatencies.Report.Users[1], userLatencies.Report.Users[0]
178+
}
179+
return userLatencies.Report.Users[0].LatencyMS != nil
180+
}, testutil.WaitShort, testutil.IntervalFast, "user latency is missing")
181+
182+
require.Len(t, userLatencies.Report.Users, 2, "only 2 users should be included")
183+
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "expected p50 to be greater than 0")
184+
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "expected p95 to be greater than 0")
185+
assert.Nil(t, userLatencies.Report.Users[1].LatencyMS, "user 2 should have no latency")
146186
}
147187

148188
func TestTemplateInsights(t *testing.T) {
@@ -183,7 +223,11 @@ func TestTemplateInsights(t *testing.T) {
183223
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
184224
defer cancel()
185225

186-
templateInsights, err := client.TemplateInsights(ctx)
226+
templateInsights, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
227+
StartTime: time.Now().Add(-time.Hour),
228+
EndTime: time.Now(),
229+
Interval: codersdk.InsightsReportIntervalDay,
230+
})
187231
require.NoError(t, err)
188232

189233
t.Logf("%#v\n", templateInsights)

0 commit comments

Comments
 (0)