diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6156329cf7..b3e514c350 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1247,25 +1247,6 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD return q.db.GetTemplateDAUs(ctx, arg) } -func (q *querier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { - for _, templateID := range arg.TemplateIDs { - template, err := q.db.GetTemplateByID(ctx, templateID) - if err != nil { - return nil, err - } - - if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { - return nil, err - } - } - if len(arg.TemplateIDs) == 0 { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { - return nil, err - } - } - return q.db.GetTemplateDailyInsights(ctx, arg) -} - func (q *querier) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { for _, templateID := range arg.TemplateIDs { template, err := q.db.GetTemplateByID(ctx, templateID) @@ -1285,6 +1266,25 @@ func (q *querier) GetTemplateInsights(ctx context.Context, arg database.GetTempl return q.db.GetTemplateInsights(ctx, arg) } +func (q *querier) GetTemplateInsightsByInterval(ctx context.Context, arg database.GetTemplateInsightsByIntervalParams) ([]database.GetTemplateInsightsByIntervalRow, error) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, err + } + + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return nil, err + } + } + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } + } + return q.db.GetTemplateInsightsByInterval(ctx, arg) +} + func (q *querier) GetTemplateParameterInsights(ctx context.Context, arg database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { for _, templateID := range arg.TemplateIDs { template, err := q.db.GetTemplateByID(ctx, templateID) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index ab7363b275..252e8772b4 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -2340,92 +2340,6 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat return rs, nil } -func (q *FakeQuerier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { - err := validateDatabaseType(arg) - if err != nil { - return nil, err - } - - q.mutex.RLock() - defer q.mutex.RUnlock() - - type dailyStat struct { - startTime, endTime time.Time - userSet map[uuid.UUID]struct{} - templateIDSet map[uuid.UUID]struct{} - } - dailyStats := []dailyStat{{arg.StartTime, arg.StartTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}} - for dailyStats[len(dailyStats)-1].endTime.Before(arg.EndTime) { - dailyStats = append(dailyStats, dailyStat{dailyStats[len(dailyStats)-1].endTime, dailyStats[len(dailyStats)-1].endTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}) - } - if dailyStats[len(dailyStats)-1].endTime.After(arg.EndTime) { - dailyStats[len(dailyStats)-1].endTime = arg.EndTime - } - - for _, s := range q.workspaceAgentStats { - if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) { - continue - } - if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) { - continue - } - if s.ConnectionCount == 0 { - continue - } - - for _, ds := range dailyStats { - if s.CreatedAt.Before(ds.startTime) || s.CreatedAt.Equal(ds.endTime) || s.CreatedAt.After(ds.endTime) { - continue - } - ds.userSet[s.UserID] = struct{}{} - ds.templateIDSet[s.TemplateID] = struct{}{} - } - } - - for _, s := range q.workspaceAppStats { - w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID) - if err != nil { - return nil, err - } - - if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, w.TemplateID) { - continue - } - - for _, ds := range dailyStats { - // (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) - // OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) - // OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) - if !(((s.SessionStartedAt.After(ds.startTime) || s.SessionStartedAt.Equal(ds.startTime)) && s.SessionStartedAt.Before(ds.endTime)) || - (s.SessionEndedAt.After(ds.startTime) && s.SessionEndedAt.Before(ds.endTime)) || - (s.SessionStartedAt.Before(ds.startTime) && (s.SessionEndedAt.After(ds.endTime) || s.SessionEndedAt.Equal(ds.endTime)))) { - continue - } - - ds.userSet[s.UserID] = struct{}{} - ds.templateIDSet[w.TemplateID] = struct{}{} - } - } - - var result []database.GetTemplateDailyInsightsRow - for _, ds := range dailyStats { - templateIDs := make([]uuid.UUID, 0, len(ds.templateIDSet)) - for templateID := range ds.templateIDSet { - templateIDs = append(templateIDs, templateID) - } - slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { - return slice.Ascending(a.String(), b.String()) - }) - result = append(result, database.GetTemplateDailyInsightsRow{ - StartTime: ds.startTime, - EndTime: ds.endTime, - TemplateIDs: templateIDs, - ActiveUsers: int64(len(ds.userSet)), - }) - } - return result, nil -} - func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { @@ -2495,6 +2409,93 @@ func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTem return result, nil } +func (q *FakeQuerier) GetTemplateInsightsByInterval(ctx context.Context, arg database.GetTemplateInsightsByIntervalParams) ([]database.GetTemplateInsightsByIntervalRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + type statByInterval struct { + startTime, endTime time.Time + userSet map[uuid.UUID]struct{} + templateIDSet map[uuid.UUID]struct{} + } + + statsByInterval := []statByInterval{{arg.StartTime, arg.StartTime.AddDate(0, 0, int(arg.IntervalDays)), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}} + for statsByInterval[len(statsByInterval)-1].endTime.Before(arg.EndTime) { + statsByInterval = append(statsByInterval, statByInterval{statsByInterval[len(statsByInterval)-1].endTime, statsByInterval[len(statsByInterval)-1].endTime.AddDate(0, 0, int(arg.IntervalDays)), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}) + } + if statsByInterval[len(statsByInterval)-1].endTime.After(arg.EndTime) { + statsByInterval[len(statsByInterval)-1].endTime = arg.EndTime + } + + for _, s := range q.workspaceAgentStats { + if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) { + continue + } + if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) { + continue + } + if s.ConnectionCount == 0 { + continue + } + + for _, ds := range statsByInterval { + if s.CreatedAt.Before(ds.startTime) || s.CreatedAt.Equal(ds.endTime) || s.CreatedAt.After(ds.endTime) { + continue + } + ds.userSet[s.UserID] = struct{}{} + ds.templateIDSet[s.TemplateID] = struct{}{} + } + } + + for _, s := range q.workspaceAppStats { + w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID) + if err != nil { + return nil, err + } + + if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, w.TemplateID) { + continue + } + + for _, ds := range statsByInterval { + // (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) + // OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) + // OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) + if !(((s.SessionStartedAt.After(ds.startTime) || s.SessionStartedAt.Equal(ds.startTime)) && s.SessionStartedAt.Before(ds.endTime)) || + (s.SessionEndedAt.After(ds.startTime) && s.SessionEndedAt.Before(ds.endTime)) || + (s.SessionStartedAt.Before(ds.startTime) && (s.SessionEndedAt.After(ds.endTime) || s.SessionEndedAt.Equal(ds.endTime)))) { + continue + } + + ds.userSet[s.UserID] = struct{}{} + ds.templateIDSet[w.TemplateID] = struct{}{} + } + } + + var result []database.GetTemplateInsightsByIntervalRow + for _, ds := range statsByInterval { + templateIDs := make([]uuid.UUID, 0, len(ds.templateIDSet)) + for templateID := range ds.templateIDSet { + templateIDs = append(templateIDs, templateID) + } + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) + }) + result = append(result, database.GetTemplateInsightsByIntervalRow{ + StartTime: ds.startTime, + EndTime: ds.endTime, + TemplateIDs: templateIDs, + ActiveUsers: int64(len(ds.userSet)), + }) + } + return result, nil +} + func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 768c1d4adb..13aa97d9af 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -655,13 +655,6 @@ func (m metricsStore) GetTemplateDAUs(ctx context.Context, arg database.GetTempl return daus, err } -func (m metricsStore) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { - start := time.Now() - r0, r1 := m.s.GetTemplateDailyInsights(ctx, arg) - m.queryLatencies.WithLabelValues("GetTemplateDailyInsights").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m metricsStore) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { start := time.Now() r0, r1 := m.s.GetTemplateInsights(ctx, arg) @@ -669,6 +662,13 @@ func (m metricsStore) GetTemplateInsights(ctx context.Context, arg database.GetT return r0, r1 } +func (m metricsStore) GetTemplateInsightsByInterval(ctx context.Context, arg database.GetTemplateInsightsByIntervalParams) ([]database.GetTemplateInsightsByIntervalRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateInsightsByInterval(ctx, arg) + m.queryLatencies.WithLabelValues("GetTemplateInsightsByInterval").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetTemplateParameterInsights(ctx context.Context, arg database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { start := time.Now() r0, r1 := m.s.GetTemplateParameterInsights(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 641dd7315b..864b50f30f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1315,21 +1315,6 @@ func (mr *MockStoreMockRecorder) GetTemplateDAUs(arg0, arg1 interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDAUs", reflect.TypeOf((*MockStore)(nil).GetTemplateDAUs), arg0, arg1) } -// GetTemplateDailyInsights mocks base method. -func (m *MockStore) GetTemplateDailyInsights(arg0 context.Context, arg1 database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTemplateDailyInsights", arg0, arg1) - ret0, _ := ret[0].([]database.GetTemplateDailyInsightsRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetTemplateDailyInsights indicates an expected call of GetTemplateDailyInsights. -func (mr *MockStoreMockRecorder) GetTemplateDailyInsights(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDailyInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateDailyInsights), arg0, arg1) -} - // GetTemplateGroupRoles mocks base method. func (m *MockStore) GetTemplateGroupRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateGroup, error) { m.ctrl.T.Helper() @@ -1360,6 +1345,21 @@ func (mr *MockStoreMockRecorder) GetTemplateInsights(arg0, arg1 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateInsights), arg0, arg1) } +// GetTemplateInsightsByInterval mocks base method. +func (m *MockStore) GetTemplateInsightsByInterval(arg0 context.Context, arg1 database.GetTemplateInsightsByIntervalParams) ([]database.GetTemplateInsightsByIntervalRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateInsightsByInterval", arg0, arg1) + ret0, _ := ret[0].([]database.GetTemplateInsightsByIntervalRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateInsightsByInterval indicates an expected call of GetTemplateInsightsByInterval. +func (mr *MockStoreMockRecorder) GetTemplateInsightsByInterval(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsightsByInterval", reflect.TypeOf((*MockStore)(nil).GetTemplateInsightsByInterval), arg0, arg1) +} + // GetTemplateParameterInsights mocks base method. func (m *MockStore) GetTemplateParameterInsights(arg0 context.Context, arg1 database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 63c1f7321d..25e42dd5a6 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -124,15 +124,15 @@ type sqlcQuerier interface { GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error) - // GetTemplateDailyInsights returns all daily intervals between start and end - // time, if end time is a partial day, it will be included in the results and - // that interval will be less than 24 hours. If there is no data for a selected - // interval/template, it will be included in the results with 0 active users. - GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) // GetTemplateInsights has a granularity of 5 minutes where if a session/app was // in use during a minute, we will add 5 minutes to the total usage for that // session/app (per user). GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) + // GetTemplateInsightsByInterval returns all intervals between start and end + // time, if end time is a partial interval, it will be included in the results and + // that interval will be shorter than a full one. If there is no data for a selected + // interval/template, it will be included in the results with 0 active users. + GetTemplateInsightsByInterval(ctx context.Context, arg GetTemplateInsightsByIntervalParams) ([]GetTemplateInsightsByIntervalRow, error) // GetTemplateParameterInsights does for each template in a given timeframe, // look for the latest workspace build (for every workspace) that has been // created in the timeframe and return the aggregate usage counts of parameter diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 67b7b782b9..ffc468eb63 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1738,112 +1738,6 @@ func (q *sqlQuerier) GetTemplateAppInsights(ctx context.Context, arg GetTemplate return items, nil } -const getTemplateDailyInsights = `-- name: GetTemplateDailyInsights :many -WITH ts AS ( - SELECT - d::timestamptz AS from_, - CASE - WHEN (d::timestamptz + '1 day'::interval) <= $1::timestamptz - THEN (d::timestamptz + '1 day'::interval) - ELSE $1::timestamptz - END AS to_ - FROM - -- Subtract 1 second from end_time to avoid including the next interval in the results. - generate_series($2::timestamptz, ($1::timestamptz) - '1 second'::interval, '1 day'::interval) AS d -), unflattened_usage_by_day AS ( - -- We select data from both workspace agent stats and workspace app stats to - -- get a complete picture of usage. This matches how usage is calculated by - -- the combination of GetTemplateInsights and GetTemplateAppInsights. We use - -- a union all to avoid a costly distinct operation. - -- - -- Note that one query must perform a left join so that all intervals are - -- present at least once. - SELECT - ts.from_, ts.to_, - was.template_id, - was.user_id - FROM ts - LEFT JOIN workspace_agent_stats was ON ( - was.created_at >= ts.from_ - AND was.created_at < ts.to_ - AND was.connection_count > 0 - AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END - ) - GROUP BY ts.from_, ts.to_, was.template_id, was.user_id - - UNION ALL - - SELECT - ts.from_, ts.to_, - w.template_id, - was.user_id - FROM ts - JOIN workspace_app_stats was ON ( - (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) - OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) - OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) - ) - JOIN workspaces w ON ( - w.id = was.workspace_id - AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN w.template_id = ANY($3::uuid[]) ELSE TRUE END - ) - GROUP BY ts.from_, ts.to_, w.template_id, was.user_id -) - -SELECT - from_ AS start_time, - to_ AS end_time, - array_remove(array_agg(DISTINCT template_id), NULL)::uuid[] AS template_ids, - COUNT(DISTINCT user_id) AS active_users -FROM unflattened_usage_by_day -GROUP BY from_, to_ -` - -type GetTemplateDailyInsightsParams struct { - EndTime time.Time `db:"end_time" json:"end_time"` - StartTime time.Time `db:"start_time" json:"start_time"` - TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` -} - -type GetTemplateDailyInsightsRow struct { - StartTime time.Time `db:"start_time" json:"start_time"` - EndTime time.Time `db:"end_time" json:"end_time"` - TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` - ActiveUsers int64 `db:"active_users" json:"active_users"` -} - -// GetTemplateDailyInsights returns all daily intervals between start and end -// time, if end time is a partial day, it will be included in the results and -// that interval will be less than 24 hours. If there is no data for a selected -// interval/template, it will be included in the results with 0 active users. -func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) { - rows, err := q.db.QueryContext(ctx, getTemplateDailyInsights, arg.EndTime, arg.StartTime, pq.Array(arg.TemplateIDs)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTemplateDailyInsightsRow - for rows.Next() { - var i GetTemplateDailyInsightsRow - if err := rows.Scan( - &i.StartTime, - &i.EndTime, - pq.Array(&i.TemplateIDs), - &i.ActiveUsers, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getTemplateInsights = `-- name: GetTemplateInsights :one WITH agent_stats_by_interval_and_user AS ( SELECT @@ -1910,6 +1804,118 @@ func (q *sqlQuerier) GetTemplateInsights(ctx context.Context, arg GetTemplateIns return i, err } +const getTemplateInsightsByInterval = `-- name: GetTemplateInsightsByInterval :many +WITH ts AS ( + SELECT + d::timestamptz AS from_, + CASE + WHEN (d::timestamptz + ($1::int || ' day')::interval) <= $2::timestamptz + THEN (d::timestamptz + ($1::int || ' day')::interval) + ELSE $2::timestamptz + END AS to_ + FROM + -- Subtract 1 microsecond from end_time to avoid including the next interval in the results. + generate_series($3::timestamptz, ($2::timestamptz) - '1 microsecond'::interval, ($1::int || ' day')::interval) AS d +), unflattened_usage_by_interval AS ( + -- We select data from both workspace agent stats and workspace app stats to + -- get a complete picture of usage. This matches how usage is calculated by + -- the combination of GetTemplateInsights and GetTemplateAppInsights. We use + -- a union all to avoid a costly distinct operation. + -- + -- Note that one query must perform a left join so that all intervals are + -- present at least once. + SELECT + ts.from_, ts.to_, + was.template_id, + was.user_id + FROM ts + LEFT JOIN workspace_agent_stats was ON ( + was.created_at >= ts.from_ + AND was.created_at < ts.to_ + AND was.connection_count > 0 + AND CASE WHEN COALESCE(array_length($4::uuid[], 1), 0) > 0 THEN was.template_id = ANY($4::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, was.template_id, was.user_id + + UNION ALL + + SELECT + ts.from_, ts.to_, + w.template_id, + was.user_id + FROM ts + JOIN workspace_app_stats was ON ( + (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) + OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) + OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) + ) + JOIN workspaces w ON ( + w.id = was.workspace_id + AND CASE WHEN COALESCE(array_length($4::uuid[], 1), 0) > 0 THEN w.template_id = ANY($4::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, w.template_id, was.user_id +) + +SELECT + from_ AS start_time, + to_ AS end_time, + array_remove(array_agg(DISTINCT template_id), NULL)::uuid[] AS template_ids, + COUNT(DISTINCT user_id) AS active_users +FROM unflattened_usage_by_interval +GROUP BY from_, to_ +` + +type GetTemplateInsightsByIntervalParams struct { + IntervalDays int32 `db:"interval_days" json:"interval_days"` + EndTime time.Time `db:"end_time" json:"end_time"` + StartTime time.Time `db:"start_time" json:"start_time"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` +} + +type GetTemplateInsightsByIntervalRow struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + ActiveUsers int64 `db:"active_users" json:"active_users"` +} + +// GetTemplateInsightsByInterval returns all intervals between start and end +// time, if end time is a partial interval, it will be included in the results and +// that interval will be shorter than a full one. If there is no data for a selected +// interval/template, it will be included in the results with 0 active users. +func (q *sqlQuerier) GetTemplateInsightsByInterval(ctx context.Context, arg GetTemplateInsightsByIntervalParams) ([]GetTemplateInsightsByIntervalRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateInsightsByInterval, + arg.IntervalDays, + arg.EndTime, + arg.StartTime, + pq.Array(arg.TemplateIDs), + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateInsightsByIntervalRow + for rows.Next() { + var i GetTemplateInsightsByIntervalRow + if err := rows.Scan( + &i.StartTime, + &i.EndTime, + pq.Array(&i.TemplateIDs), + &i.ActiveUsers, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTemplateParameterInsights = `-- name: GetTemplateParameterInsights :many WITH latest_workspace_builds AS ( SELECT diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index d76d106edd..dc69b69de2 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -113,23 +113,23 @@ SELECT FROM app_stats_by_user_and_agent GROUP BY access_method, slug_or_port, display_name, icon, is_app; --- name: GetTemplateDailyInsights :many --- GetTemplateDailyInsights returns all daily intervals between start and end --- time, if end time is a partial day, it will be included in the results and --- that interval will be less than 24 hours. If there is no data for a selected +-- name: GetTemplateInsightsByInterval :many +-- GetTemplateInsightsByInterval returns all intervals between start and end +-- time, if end time is a partial interval, it will be included in the results and +-- that interval will be shorter than a full one. If there is no data for a selected -- interval/template, it will be included in the results with 0 active users. WITH ts AS ( SELECT d::timestamptz AS from_, CASE - WHEN (d::timestamptz + '1 day'::interval) <= @end_time::timestamptz - THEN (d::timestamptz + '1 day'::interval) + WHEN (d::timestamptz + (@interval_days::int || ' day')::interval) <= @end_time::timestamptz + THEN (d::timestamptz + (@interval_days::int || ' day')::interval) ELSE @end_time::timestamptz END AS to_ FROM - -- Subtract 1 second from end_time to avoid including the next interval in the results. - generate_series(@start_time::timestamptz, (@end_time::timestamptz) - '1 second'::interval, '1 day'::interval) AS d -), unflattened_usage_by_day AS ( + -- Subtract 1 microsecond from end_time to avoid including the next interval in the results. + generate_series(@start_time::timestamptz, (@end_time::timestamptz) - '1 microsecond'::interval, (@interval_days::int || ' day')::interval) AS d +), unflattened_usage_by_interval AS ( -- We select data from both workspace agent stats and workspace app stats to -- get a complete picture of usage. This matches how usage is calculated by -- the combination of GetTemplateInsights and GetTemplateAppInsights. We use @@ -174,7 +174,7 @@ SELECT to_ AS end_time, array_remove(array_agg(DISTINCT template_id), NULL)::uuid[] AS template_ids, COUNT(DISTINCT user_id) AS active_users -FROM unflattened_usage_by_day +FROM unflattened_usage_by_interval GROUP BY from_, to_; -- name: GetTemplateParameterInsights :many diff --git a/coderd/insights.go b/coderd/insights.go index e19f95d40d..6b2c121033 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -191,7 +191,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { var usage database.GetTemplateInsightsRow var appUsage []database.GetTemplateAppInsightsRow - var dailyUsage []database.GetTemplateDailyInsightsRow + var dailyUsage []database.GetTemplateInsightsByIntervalRow var parameterRows []database.GetTemplateParameterInsightsRow eg, egCtx := errgroup.WithContext(ctx) @@ -203,10 +203,11 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { eg.Go(func() error { var err error if interval != "" { - dailyUsage, err = api.Database.GetTemplateDailyInsights(egCtx, database.GetTemplateDailyInsightsParams{ - StartTime: startTime, - EndTime: endTime, - TemplateIDs: templateIDs, + dailyUsage, err = api.Database.GetTemplateInsightsByInterval(egCtx, database.GetTemplateInsightsByIntervalParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + IntervalDays: 1, }) if err != nil { return xerrors.Errorf("get template daily insights: %w", err)