Skip to content

fix(coderd/database): aggregate user engagement statistics by interval #16150

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
update comments and timezone handling
  • Loading branch information
SasSwart committed Jan 15, 2025
commit 3fa8ca72f742a447c23856e2db08e793ddad0be1
49 changes: 31 additions & 18 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2411,12 +2411,9 @@ func TestGetUserStatusCounts(t *testing.T) {
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)

end := dbtime.Now()
start := end.Add(-30 * 24 * time.Hour)

counts, err := db.GetUserStatusCounts(ctx, database.GetUserStatusCountsParams{
StartTime: start,
EndTime: end,
StartTime: createdAt,
EndTime: today,
})
require.NoError(t, err)
require.Empty(t, counts, "should return no results when there are no users")
Expand Down Expand Up @@ -2457,23 +2454,33 @@ func TestGetUserStatusCounts(t *testing.T) {
UpdatedAt: createdAt,
})

// Query for the last 30 days
_, offset := today.Zone()
userStatusChanges, err := db.GetUserStatusCounts(ctx, database.GetUserStatusCountsParams{
StartTime: createdAt,
EndTime: today,
StartTime: startOfDay(createdAt),
EndTime: startOfDay(today),
TzOffset: int32(offset),
})
require.NoError(t, err)
require.NotEmpty(t, userStatusChanges, "should return results")

require.Len(t, userStatusChanges, 2, "should have 1 entry per status change plus and 1 entry for the end of the range = 2 entries")

require.Equal(t, userStatusChanges[0].Status, tc.status, "should have the correct status")
require.Equal(t, userStatusChanges[0].Count, int64(1), "should have 1 user")
require.True(t, userStatusChanges[0].Date.Equal(createdAt), "should have the correct date")

require.Equal(t, userStatusChanges[1].Status, tc.status, "should have the correct status")
require.Equal(t, userStatusChanges[1].Count, int64(1), "should have 1 user")
require.True(t, userStatusChanges[1].Date.Equal(today), "should have the correct date")
numDays := int(startOfDay(today).Sub(startOfDay(createdAt)).Hours() / 24)
require.Len(t, userStatusChanges, numDays+1, "should have 1 entry per day between the start and end time, including the end time")

for i, row := range userStatusChanges {
require.Equal(t, tc.status, row.Status, "should have the correct status")
require.True(
t,
row.Date.In(location).Equal(startOfDay(createdAt).AddDate(0, 0, i)),
"expected date %s, but got %s for row %n",
startOfDay(createdAt).AddDate(0, 0, i),
row.Date.In(location).String(),
i,
)
if row.Date.Before(startOfDay(createdAt)) {
require.Equal(t, int64(0), row.Count, "should have 0 users before creation")
} else {
require.Equal(t, int64(1), row.Count, "should have 1 user after creation")
}
}
})
}
})
Expand Down Expand Up @@ -2724,6 +2731,7 @@ func TestGetUserStatusCounts(t *testing.T) {
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.SkipNow()
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
Expand Down Expand Up @@ -2900,3 +2908,8 @@ func requireUsersMatch(t testing.TB, expected []database.User, found []database.
t.Helper()
require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg)
}

func startOfDay(t time.Time) time.Time {
year, month, day := t.Date()
return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
}
172 changes: 86 additions & 86 deletions coderd/database/queries/insights.sql
Original file line number Diff line number Diff line change
Expand Up @@ -777,98 +777,98 @@ GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.de
-- The time range is inclusively defined by the start_time and end_time parameters.
--
-- Bucketing:
-- Between the start_time and end_time, we include each timestamp where a user's status changed or they were deleted.
-- We do not bucket these results by day or some other time unit. This is because such bucketing would hide potentially
-- important patterns. If a user was active for 23 hours and 59 minutes, and then suspended, a daily bucket would hide this.
-- A daily bucket would also have required us to carefully manage the timezone of the bucket based on the timezone of the user.
-- Between the start_time and end_time, we generate buckets based on the interval parameter
-- (defaulting to daily if interval <= 0). The timestamps are adjusted by the tz_offset
-- parameter to align with the desired timezone.
--
-- Accumulation:
-- We do not start counting from 0 at the start_time. We check the last status change before the start_time for each user. As such,
-- the result shows the total number of users in each status on any particular day.
-- We do not start counting from 0 at the start_time. We check the last status change
-- before the start_time for each user. As such, the result shows the total number of
-- users in each status at each interval.
WITH
-- dates_of_interest defines all points in time that are relevant to the query.
-- It includes the start_time, all status changes, all deletions, and the end_time.
dates_of_interest AS (
SELECT
(generate_series(
@start_time::timestamptz,
@end_time::timestamptz,
(@interval::int || ' seconds')::interval
) + (@tz_offset::int || ' seconds')::interval)::timestamptz AS date
),
-- latest_status_before_range defines the status of each user before the start_time.
-- We do not include users who were deleted before the start_time. We use this to ensure that
-- we correctly count users prior to the start_time for a complete graph.
latest_status_before_range AS (
SELECT
DISTINCT usc.user_id,
usc.new_status,
usc.changed_at,
ud.deleted
FROM user_status_changes usc
LEFT JOIN LATERAL (
SELECT COUNT(*) > 0 AS deleted
FROM user_deleted ud
WHERE ud.user_id = usc.user_id AND (ud.deleted_at < usc.changed_at OR ud.deleted_at < @start_time)
) AS ud ON true
WHERE usc.changed_at < @start_time::timestamptz
ORDER BY usc.user_id, usc.changed_at DESC
),
-- status_changes_during_range defines the status of each user during the start_time and end_time.
-- If a user is deleted during the time range, we count status changes between the start_time and the deletion date.
-- Theoretically, it should probably not be possible to update the status of a deleted user, but we
-- need to ensure that this is enforced, so that a change in business logic later does not break this graph.
status_changes_during_range AS (
SELECT
usc.user_id,
usc.new_status,
usc.changed_at,
ud.deleted
FROM user_status_changes usc
LEFT JOIN LATERAL (
SELECT COUNT(*) > 0 AS deleted
FROM user_deleted ud
WHERE ud.user_id = usc.user_id AND ud.deleted_at < usc.changed_at
) AS ud ON true
WHERE usc.changed_at >= @start_time::timestamptz
AND usc.changed_at <= @end_time::timestamptz
),
-- relevant_status_changes defines the status of each user at any point in time.
-- It includes the status of each user before the start_time, and the status of each user during the start_time and end_time.
relevant_status_changes AS (
SELECT
user_id,
new_status,
changed_at
FROM latest_status_before_range
WHERE NOT deleted
-- It generates regular intervals between start_time and end_time based on the interval
-- parameter, adjusted by the timezone offset.
dates_of_interest AS (
SELECT
(generate_series(
@start_time::timestamptz + (@tz_offset::int || ' seconds')::interval,
@end_time::timestamptz + (@tz_offset::int || ' seconds')::interval,
(CASE WHEN @interval::int <= 0 THEN 3600 * 24 ELSE @interval::int END|| ' seconds')::interval
))::timestamptz AS date
),
-- latest_status_before_range finds each user's last status before start_time.
-- Users who were deleted before start_time are excluded. This ensures accurate
-- initial counts at the start of the time range.
latest_status_before_range AS (
SELECT
DISTINCT usc.user_id,
usc.new_status,
usc.changed_at,
ud.deleted
FROM user_status_changes usc
LEFT JOIN LATERAL (
SELECT COUNT(*) > 0 AS deleted
FROM user_deleted ud
WHERE ud.user_id = usc.user_id AND (ud.deleted_at < usc.changed_at OR ud.deleted_at < @start_time)
) AS ud ON true
WHERE usc.changed_at < @start_time::timestamptz
ORDER BY usc.user_id, usc.changed_at DESC
),
-- status_changes_during_range captures all status changes between start_time and end_time.
-- Changes after a user's deletion date are excluded, as deleted users should not have
-- status changes.
status_changes_during_range AS (
SELECT
usc.user_id,
usc.new_status,
usc.changed_at,
ud.deleted
FROM user_status_changes usc
LEFT JOIN LATERAL (
SELECT COUNT(*) > 0 AS deleted
FROM user_deleted ud
WHERE ud.user_id = usc.user_id AND ud.deleted_at < usc.changed_at
) AS ud ON true
WHERE usc.changed_at >= @start_time::timestamptz
AND usc.changed_at <= @end_time::timestamptz
),
-- relevant_status_changes combines user statuses from before and during the time range.
-- Only includes non-deleted users to ensure accurate counting.
relevant_status_changes AS (
SELECT
user_id,
new_status,
changed_at
FROM latest_status_before_range
WHERE NOT deleted

UNION ALL
UNION ALL

SELECT
user_id,
new_status,
changed_at
FROM status_changes_during_range
WHERE NOT deleted
),
-- statuses defines all the distinct statuses that were present just before and during the time range.
-- This is used to ensure that we have a series for every relevant status.
statuses AS (
SELECT DISTINCT new_status FROM relevant_status_changes
),
-- We only want to count the latest status change for each user on each date and then filter them by the relevant status.
-- We use the row_number function to ensure that we only count the latest status change for each user on each date.
-- We then filter the status changes by the relevant status in the final select statement below.
ranked_status_change_per_user_per_date AS (
SELECT
d.date,
rsc1.user_id,
ROW_NUMBER() OVER (PARTITION BY d.date, rsc1.user_id ORDER BY rsc1.changed_at DESC) AS rn,
rsc1.new_status
FROM dates_of_interest d
LEFT JOIN relevant_status_changes rsc1 ON rsc1.changed_at <= d.date
)
SELECT
user_id,
new_status,
changed_at
FROM status_changes_during_range
WHERE NOT deleted
),
-- statuses collects all distinct status values that appeared in the time range
-- or just before it, ensuring we have a complete set of statuses to count.
statuses AS (
SELECT DISTINCT new_status FROM relevant_status_changes
),
-- ranked_status_change_per_user_per_date finds the latest status for each user
-- at each interval date. The row_number helps us select only the most recent
-- status change when multiple changes occur before an interval.
ranked_status_change_per_user_per_date AS (
SELECT
d.date,
rsc1.user_id,
ROW_NUMBER() OVER (PARTITION BY d.date, rsc1.user_id ORDER BY rsc1.changed_at DESC) AS rn,
rsc1.new_status
FROM dates_of_interest d
LEFT JOIN relevant_status_changes rsc1 ON rsc1.changed_at <= d.date
)
SELECT
rscpupd.date,
statuses.new_status AS status,
Expand Down