fix: user status change chart accommodates DST (#22191)

closes https://github.com/coder/internal/issues/464

# Summary

This PR resolves a flaky test that was sensitive to DST transitions in
various time zones. The root of the flake was:
* a bug; the query and its tests assume 24 hours per day
* the tests used local system time, which resulted in failures for dates
proximal to DST transitions

# Changes

Query:

The original query assumed 24 hour intervals between each day, which is
not a valid assumption. It now increments `1 day` at a time.

Database tests:

Database level tests for the query all assumed 24 hour days. They now
increment in DST-aware days instead. Instead of using time.Now() as a
base for testing, the test uses a series of dates over the course of an
entire year, to ensure that DST transition dates are present in every
test run.

# API Endpoint

The endpoint that delivers the user status chart now accepts an IANA
timezone name as a parameter and passes it, keeping the existing offset
as a fallback, to the database query.

API level tests were added to ensure the correct response form and error
behaviour. Correctness of content is tested at the database level.
This commit is contained in:
Sas Swart
2026-03-04 12:54:39 +02:00
committed by GitHub
parent 2882e36222
commit cfcb81fb0f
13 changed files with 407 additions and 269 deletions
+38 -7
View File
@@ -298,7 +298,8 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Produce json
// @Tags Insights
// @Param tz_offset query int true "Time-zone offset (e.g. -2)"
// @Param timezone query string false "IANA timezone name (e.g. America/St_Johns)"
// @Param tz_offset query int false "Deprecated: Time-zone offset (e.g. -2). Use timezone instead."
// @Success 200 {object} codersdk.GetUserStatusCountsResponse
// @Router /insights/user-status-counts [get]
func (api *API) insightsUserStatusCounts(rw http.ResponseWriter, r *http.Request) {
@@ -306,8 +307,9 @@ func (api *API) insightsUserStatusCounts(rw http.ResponseWriter, r *http.Request
p := httpapi.NewQueryParamParser()
vals := r.URL.Query()
timezone := p.String(vals, "", "timezone")
tzOffset := p.Int(vals, 0, "tz_offset")
interval := p.Int(vals, int((24 * time.Hour).Seconds()), "interval")
_ = p.Int(vals, 0, "interval") // Deprecated: ignored, kept for backward compatibility.
p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
@@ -318,16 +320,45 @@ func (api *API) insightsUserStatusCounts(rw http.ResponseWriter, r *http.Request
return
}
loc := time.FixedZone("", tzOffset*3600)
if timezone != "" && tzOffset != 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Provide either \"timezone\" or \"tz_offset\", not both.",
})
return
}
var loc *time.Location
if timezone == "" {
timezone = "UTC"
if tzOffset > 0 {
timezone = fmt.Sprintf("Etc/GMT-%d", tzOffset)
} else if tzOffset < 0 {
timezone = fmt.Sprintf("Etc/GMT+%d", -tzOffset)
}
}
loc, err := time.LoadLocation(timezone)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid timezone.",
Detail: err.Error(),
})
return
}
nextHourInLoc := dbtime.Now().Truncate(time.Hour).Add(time.Hour).In(loc)
sixtyDaysAgo := dbtime.StartOfDay(nextHourInLoc).AddDate(0, 0, -60)
rows, err := api.Database.GetUserStatusCounts(ctx, database.GetUserStatusCountsParams{
queryParams := database.GetUserStatusCountsParams{
StartTime: sixtyDaysAgo,
EndTime: nextHourInLoc,
// #nosec G115 - Interval value is small and fits in int32 (typically days or hours)
Interval: int32(interval),
})
// loc.String() returns an IANA timezone name (e.g. "America/New_York").
// Both Go and PostgreSQL use the IANA Time Zone Database, so names are
// compatible. The Etc/GMT±N names used for offset fallback are also valid
// in both systems.
Tz: loc.String(),
}
rows, err := api.Database.GetUserStatusCounts(ctx, queryParams)
if err != nil {
if httpapi.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)