Skip to content

Commit b0e3daa

Browse files
authored
feat(coderd): support weekly aggregated insights (#9684)
1 parent b358e3d commit b0e3daa

15 files changed

+680
-24
lines changed

coderd/apidoc/docs.go

+10-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+11-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaceagentstats.sql

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ ORDER BY
9090
date ASC;
9191

9292
-- name: DeleteOldWorkspaceAgentStats :exec
93-
DELETE FROM workspace_agent_stats WHERE created_at < NOW() - INTERVAL '30 days';
93+
DELETE FROM workspace_agent_stats WHERE created_at < NOW() - INTERVAL '6 months';
9494

9595
-- name: GetDeploymentWorkspaceAgentStats :one
9696
WITH agent_stats AS (

coderd/insights.go

+24-5
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
184184
if !ok {
185185
return
186186
}
187-
interval, ok := verifyInsightsInterval(ctx, rw, intervalString)
187+
interval, ok := parseInsightsInterval(ctx, rw, intervalString, startTime, endTime)
188188
if !ok {
189189
return
190190
}
@@ -198,7 +198,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
198198
eg.SetLimit(4)
199199

200200
// The following insights data queries have a theoretical chance to be
201-
// inconsistent between eachother when looking at "today", however, the
201+
// inconsistent between each other when looking at "today", however, the
202202
// overhead from a transaction is not worth it.
203203
eg.Go(func() error {
204204
var err error
@@ -207,7 +207,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
207207
StartTime: startTime,
208208
EndTime: endTime,
209209
TemplateIDs: templateIDs,
210-
IntervalDays: 1,
210+
IntervalDays: interval.Days(),
211211
})
212212
if err != nil {
213213
return xerrors.Errorf("get template daily insights: %w", err)
@@ -531,20 +531,39 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s
531531
return startTime, endTime, true
532532
}
533533

534-
func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string) (codersdk.InsightsReportInterval, bool) {
534+
func parseInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string, startTime, endTime time.Time) (codersdk.InsightsReportInterval, bool) {
535535
switch v := codersdk.InsightsReportInterval(intervalString); v {
536536
case codersdk.InsightsReportIntervalDay, "":
537537
return v, true
538+
case codersdk.InsightsReportIntervalWeek:
539+
if !lastReportIntervalHasAtLeastSixDays(startTime, endTime) {
540+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
541+
Message: "Query parameter has invalid value.",
542+
Detail: "Last report interval should have at least 6 days.",
543+
})
544+
return "", false
545+
}
546+
return v, true
538547
default:
539548
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
540549
Message: "Query parameter has invalid value.",
541550
Validations: []codersdk.ValidationError{
542551
{
543552
Field: "interval",
544-
Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalDay}),
553+
Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalDay, codersdk.InsightsReportIntervalWeek}),
545554
},
546555
},
547556
})
548557
return "", false
549558
}
550559
}
560+
561+
func lastReportIntervalHasAtLeastSixDays(startTime, endTime time.Time) bool {
562+
lastReportIntervalDays := endTime.Sub(startTime) % (7 * 24 * time.Hour)
563+
if lastReportIntervalDays == 0 {
564+
return true // this is a perfectly full week!
565+
}
566+
// Ensure that the last interval has at least 6 days, or check the special case, forward DST change,
567+
// when the duration can be shorter than 6 days: 5 days 23 hours.
568+
return lastReportIntervalDays >= 6*24*time.Hour || startTime.AddDate(0, 0, 6).Equal(endTime)
569+
}

coderd/insights_internal_test.go

+155
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88

99
"github.com/stretchr/testify/assert"
1010
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/coder/v2/codersdk"
1113
)
1214

1315
func Test_parseInsightsStartAndEndTime(t *testing.T) {
@@ -148,3 +150,156 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) {
148150
})
149151
}
150152
}
153+
154+
func Test_parseInsightsInterval_week(t *testing.T) {
155+
t.Parallel()
156+
157+
layout := insightsTimeLayout
158+
sydneyLoc, err := time.LoadLocation("Australia/Sydney") // Random location
159+
require.NoError(t, err)
160+
161+
now := time.Now()
162+
y, m, d := now.Date()
163+
today := time.Date(y, m, d, 0, 0, 0, 0, sydneyLoc)
164+
165+
thisHour := time.Date(y, m, d, now.Hour(), 0, 0, 0, sydneyLoc)
166+
twoHoursAgo := thisHour.Add(-2 * time.Hour)
167+
thirteenDaysAgo := today.AddDate(0, 0, -13)
168+
169+
sixDaysAgo := today.AddDate(0, 0, -6)
170+
nineDaysAgo := today.AddDate(0, 0, -9)
171+
172+
type args struct {
173+
startTime string
174+
endTime string
175+
}
176+
tests := []struct {
177+
name string
178+
args args
179+
wantOk bool
180+
}{
181+
{
182+
name: "Two full weeks",
183+
args: args{
184+
startTime: "2023-08-10T00:00:00+02:00",
185+
endTime: "2023-08-24T00:00:00+02:00",
186+
},
187+
wantOk: true,
188+
},
189+
{
190+
name: "Two weeks",
191+
args: args{
192+
startTime: thirteenDaysAgo.Format(layout),
193+
endTime: twoHoursAgo.Format(layout),
194+
},
195+
wantOk: true,
196+
},
197+
{
198+
name: "One full week",
199+
args: args{
200+
startTime: "2023-09-06T00:00:00+02:00",
201+
endTime: "2023-09-13T00:00:00+02:00",
202+
},
203+
wantOk: true,
204+
},
205+
{
206+
name: "6 days are acceptable",
207+
args: args{
208+
startTime: sixDaysAgo.Format(layout),
209+
endTime: thisHour.Format(layout),
210+
},
211+
wantOk: true,
212+
},
213+
{
214+
name: "Shorter than a full week",
215+
args: args{
216+
startTime: "2023-09-08T00:00:00+02:00",
217+
endTime: "2023-09-13T00:00:00+02:00",
218+
},
219+
wantOk: false,
220+
},
221+
{
222+
name: "9 days (7 + 2) are not acceptable",
223+
args: args{
224+
startTime: nineDaysAgo.Format(layout),
225+
endTime: thisHour.Format(layout),
226+
},
227+
wantOk: false,
228+
},
229+
}
230+
for _, tt := range tests {
231+
tt := tt
232+
t.Run(tt.name, func(t *testing.T) {
233+
t.Parallel()
234+
235+
rw := httptest.NewRecorder()
236+
startTime, endTime, ok := parseInsightsStartAndEndTime(context.Background(), rw, tt.args.startTime, tt.args.endTime)
237+
if !ok {
238+
//nolint:bodyclose
239+
t.Log("Status: ", rw.Result().StatusCode)
240+
t.Log("Body: ", rw.Body.String())
241+
}
242+
require.True(t, ok, "start_time and end_time must be valid")
243+
244+
parsedInterval, gotOk := parseInsightsInterval(context.Background(), rw, "week", startTime, endTime)
245+
if !assert.Equal(t, tt.wantOk, gotOk) {
246+
//nolint:bodyclose
247+
t.Log("Status: ", rw.Result().StatusCode)
248+
t.Log("Body: ", rw.Body.String())
249+
}
250+
if tt.wantOk {
251+
assert.Equal(t, codersdk.InsightsReportIntervalWeek, parsedInterval)
252+
}
253+
})
254+
}
255+
}
256+
257+
func TestLastReportIntervalHasAtLeastSixDays(t *testing.T) {
258+
t.Parallel()
259+
260+
loc, err := time.LoadLocation("Europe/Warsaw")
261+
require.NoError(t, err)
262+
263+
testCases := []struct {
264+
name string
265+
startTime time.Time
266+
endTime time.Time
267+
expected bool
268+
}{
269+
{
270+
name: "perfectly full week",
271+
startTime: time.Date(2023, time.September, 11, 12, 0, 0, 0, loc),
272+
endTime: time.Date(2023, time.September, 18, 12, 0, 0, 0, loc),
273+
expected: true,
274+
},
275+
{
276+
name: "exactly 6 days apart",
277+
startTime: time.Date(2023, time.September, 11, 12, 0, 0, 0, loc),
278+
endTime: time.Date(2023, time.September, 17, 12, 0, 0, 0, loc),
279+
expected: true,
280+
},
281+
{
282+
name: "less than 6 days apart",
283+
startTime: time.Date(2023, time.September, 11, 12, 0, 0, 0, time.UTC),
284+
endTime: time.Date(2023, time.September, 17, 11, 0, 0, 0, time.UTC),
285+
expected: false,
286+
},
287+
{
288+
name: "forward DST change, 5 days and 23 hours apart",
289+
startTime: time.Date(2023, time.March, 22, 12, 0, 0, 0, loc), // A day before DST starts
290+
endTime: time.Date(2023, time.March, 28, 12, 0, 0, 0, loc), // Exactly 6 "days" apart
291+
expected: true,
292+
},
293+
}
294+
295+
for _, tc := range testCases {
296+
tc := tc
297+
t.Run(tc.name, func(t *testing.T) {
298+
t.Parallel()
299+
result := lastReportIntervalHasAtLeastSixDays(tc.startTime, tc.endTime)
300+
if result != tc.expected {
301+
t.Errorf("Expected %v, but got %v for start time %v and end time %v", tc.expected, result, tc.startTime, tc.endTime)
302+
}
303+
})
304+
}
305+
}

0 commit comments

Comments
 (0)