diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 96be09c86c324..6053d3bbb8813 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -934,6 +934,31 @@ const docTemplate = `{ } } }, + "/insights/user-activity": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Insights" + ], + "summary": "Get insights about user activity", + "operationId": "get-insights-about-user-activity", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserActivityInsightsResponse" + } + } + } + } + }, "/insights/user-latency": { "get": { "security": [ @@ -10378,6 +10403,67 @@ const docTemplate = `{ } } }, + "codersdk.UserActivity": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "seconds": { + "type": "integer", + "example": 80500 + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, + "codersdk.UserActivityInsightsReport": { + "type": "object", + "properties": { + "end_time": { + "type": "string", + "format": "date-time" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserActivity" + } + } + } + }, + "codersdk.UserActivityInsightsResponse": { + "type": "object", + "properties": { + "report": { + "$ref": "#/definitions/codersdk.UserActivityInsightsReport" + } + } + }, "codersdk.UserLatency": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1d881c3868a3e..a7c589c984620 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -804,6 +804,27 @@ } } }, + "/insights/user-activity": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Insights"], + "summary": "Get insights about user activity", + "operationId": "get-insights-about-user-activity", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserActivityInsightsResponse" + } + } + } + } + }, "/insights/user-latency": { "get": { "security": [ @@ -9392,6 +9413,67 @@ } } }, + "codersdk.UserActivity": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "seconds": { + "type": "integer", + "example": 80500 + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, + "codersdk.UserActivityInsightsReport": { + "type": "object", + "properties": { + "end_time": { + "type": "string", + "format": "date-time" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserActivity" + } + } + } + }, + "codersdk.UserActivityInsightsResponse": { + "type": "object", + "properties": { + "report": { + "$ref": "#/definitions/codersdk.UserActivityInsightsReport" + } + } + }, "codersdk.UserLatency": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index ccbb0fc703959..24f0b38f08dc9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -895,6 +895,7 @@ func New(options *Options) *API { r.Route("/insights", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Get("/daus", api.deploymentDAUs) + r.Get("/user-activity", api.insightsUserActivity) r.Get("/user-latency", api.insightsUserLatency) r.Get("/templates", api.insightsTemplates) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9e0d875c55f7a..a8d906fbf83c8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1478,6 +1478,25 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, return q.db.GetUnexpiredLicenses(ctx) } +func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, 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.GetUserActivityInsights(ctx, arg) +} + func (q *querier) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { return fetch(q.log, q.auth, q.db.GetUserByEmailOrUsername)(ctx, arg) } diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 3800395b2db23..c7cc31ef243bb 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -2831,6 +2831,142 @@ func (q *FakeQuerier) GetUnexpiredLicenses(_ context.Context) ([]database.Licens return results, nil } +func (q *FakeQuerier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + type uniqueKey struct { + TemplateID uuid.UUID + UserID uuid.UUID + } + + combinedStats := make(map[uniqueKey]map[time.Time]int64) + + // Get application stats + for _, s := range q.workspaceAppStats { + if !(((s.SessionStartedAt.After(arg.StartTime) || s.SessionStartedAt.Equal(arg.StartTime)) && s.SessionStartedAt.Before(arg.EndTime)) || + (s.SessionEndedAt.After(arg.StartTime) && s.SessionEndedAt.Before(arg.EndTime)) || + (s.SessionStartedAt.Before(arg.StartTime) && (s.SessionEndedAt.After(arg.EndTime) || s.SessionEndedAt.Equal(arg.EndTime)))) { + continue + } + + 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 + } + + key := uniqueKey{ + TemplateID: w.TemplateID, + UserID: s.UserID, + } + if combinedStats[key] == nil { + combinedStats[key] = make(map[time.Time]int64) + } + + t := s.SessionStartedAt.Truncate(time.Minute) + if t.Before(arg.StartTime) { + t = arg.StartTime + } + for t.Before(s.SessionEndedAt) && t.Before(arg.EndTime) { + combinedStats[key][t] = 60 + t = t.Add(1 * time.Minute) + } + } + + // Get session stats + 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 + } + + key := uniqueKey{ + TemplateID: s.TemplateID, + UserID: s.UserID, + } + + if combinedStats[key] == nil { + combinedStats[key] = make(map[time.Time]int64) + } + + if s.SessionCountJetBrains > 0 || s.SessionCountVSCode > 0 || s.SessionCountReconnectingPTY > 0 || s.SessionCountSSH > 0 { + t := s.CreatedAt.Truncate(time.Minute) + combinedStats[key][t] = 60 + } + } + + // Use temporary maps for aggregation purposes + mUserIDTemplateIDs := map[uuid.UUID]map[uuid.UUID]struct{}{} + mUserIDUsageSeconds := map[uuid.UUID]int64{} + + for key, times := range combinedStats { + if mUserIDTemplateIDs[key.UserID] == nil { + mUserIDTemplateIDs[key.UserID] = make(map[uuid.UUID]struct{}) + mUserIDUsageSeconds[key.UserID] = 0 + } + + if _, ok := mUserIDTemplateIDs[key.UserID][key.TemplateID]; !ok { + mUserIDTemplateIDs[key.UserID][key.TemplateID] = struct{}{} + } + + for _, t := range times { + mUserIDUsageSeconds[key.UserID] += t + } + } + + userIDs := make([]uuid.UUID, 0, len(mUserIDUsageSeconds)) + for userID := range mUserIDUsageSeconds { + userIDs = append(userIDs, userID) + } + sort.Slice(userIDs, func(i, j int) bool { + return userIDs[i].String() < userIDs[j].String() + }) + + // Finally, select stats + var rows []database.GetUserActivityInsightsRow + + for _, userID := range userIDs { + user, err := q.getUserByIDNoLock(userID) + if err != nil { + return nil, err + } + + tids := mUserIDTemplateIDs[userID] + templateIDs := make([]uuid.UUID, 0, len(tids)) + for key := range tids { + templateIDs = append(templateIDs, key) + } + sort.Slice(templateIDs, func(i, j int) bool { + return templateIDs[i].String() < templateIDs[j].String() + }) + + row := database.GetUserActivityInsightsRow{ + UserID: user.ID, + Username: user.Username, + AvatarURL: user.AvatarURL, + TemplateIDs: templateIDs, + UsageSeconds: mUserIDUsageSeconds[userID], + } + + rows = append(rows, row) + } + return rows, nil +} + func (q *FakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 5972b6e5c4db1..a1aac01a5bff1 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -774,6 +774,13 @@ func (m metricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database.Lice return licenses, err } +func (m metricsStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) { + start := time.Now() + r0, r1 := m.s.GetUserActivityInsights(ctx, arg) + m.queryLatencies.WithLabelValues("GetUserActivityInsights").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { start := time.Now() user, err := m.s.GetUserByEmailOrUsername(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e2a515793f22d..edd7dc567742b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1598,6 +1598,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(arg0 interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), arg0) } +// GetUserActivityInsights mocks base method. +func (m *MockStore) GetUserActivityInsights(arg0 context.Context, arg1 database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserActivityInsights", arg0, arg1) + ret0, _ := ret[0].([]database.GetUserActivityInsightsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserActivityInsights indicates an expected call of GetUserActivityInsights. +func (mr *MockStoreMockRecorder) GetUserActivityInsights(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserActivityInsights", reflect.TypeOf((*MockStore)(nil).GetUserActivityInsights), arg0, arg1) +} + // GetUserByEmailOrUsername mocks base method. func (m *MockStore) GetUserByEmailOrUsername(arg0 context.Context, arg1 database.GetUserByEmailOrUsernameParams) (database.User, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2a9ac3149385d..a9b3489d1f86b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -153,6 +153,14 @@ type sqlcQuerier interface { GetTemplates(ctx context.Context) ([]Template, error) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) + // GetUserActivityInsights returns the ranking with top active users. + // The result can be filtered on template_ids, meaning only user data from workspaces + // based on those templates will be included. + // Note: When selecting data from multiple templates or the entire deployment, + // be aware that it may lead to an increase in "usage" numbers (cumulative). In such cases, + // users may be counted multiple times for the same time interval if they have used multiple templates + // simultaneously. + GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0ee232273737c..70930cb120038 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2022,6 +2022,127 @@ func (q *sqlQuerier) GetTemplateParameterInsights(ctx context.Context, arg GetTe return items, nil } +const getUserActivityInsights = `-- name: GetUserActivityInsights :many +WITH app_stats AS ( + SELECT + s.start_time, + was.user_id, + w.template_id, + 60 as seconds + FROM workspace_app_stats was + JOIN workspaces w ON ( + w.id = was.workspace_id + AND CASE WHEN COALESCE(array_length($1::uuid[], 1), 0) > 0 THEN w.template_id = ANY($1::uuid[]) ELSE TRUE END + ) + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= $2::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < ($3::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id +), session_stats AS ( + SELECT + date_trunc('minute', was.created_at) as start_time, + was.user_id, + was.template_id, + CASE WHEN + SUM(was.session_count_vscode) > 0 OR + SUM(was.session_count_jetbrains) > 0 OR + SUM(was.session_count_reconnecting_pty) > 0 OR + SUM(was.session_count_ssh) > 0 + THEN 60 ELSE 0 END as seconds + FROM workspace_agent_stats was + WHERE + was.created_at >= $2::timestamptz + AND was.created_at < $3::timestamptz + AND was.connection_count > 0 + AND CASE WHEN COALESCE(array_length($1::uuid[], 1), 0) > 0 THEN was.template_id = ANY($1::uuid[]) ELSE TRUE END + GROUP BY date_trunc('minute', was.created_at), was.user_id, was.template_id +), combined_stats AS ( + SELECT + user_id, + template_id, + start_time, + seconds + FROM app_stats + UNION + SELECT + user_id, + template_id, + start_time, + seconds + FROM session_stats +) +SELECT + users.id as user_id, + users.username, + users.avatar_url, + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + SUM(seconds) AS usage_seconds +FROM combined_stats +JOIN users ON (users.id = combined_stats.user_id) +GROUP BY users.id, username, avatar_url +ORDER BY user_id ASC +` + +type GetUserActivityInsightsParams struct { + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` +} + +type GetUserActivityInsightsRow struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Username string `db:"username" json:"username"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + UsageSeconds int64 `db:"usage_seconds" json:"usage_seconds"` +} + +// GetUserActivityInsights returns the ranking with top active users. +// The result can be filtered on template_ids, meaning only user data from workspaces +// based on those templates will be included. +// Note: When selecting data from multiple templates or the entire deployment, +// be aware that it may lead to an increase in "usage" numbers (cumulative). In such cases, +// users may be counted multiple times for the same time interval if they have used multiple templates +// simultaneously. +func (q *sqlQuerier) GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error) { + rows, err := q.db.QueryContext(ctx, getUserActivityInsights, pq.Array(arg.TemplateIDs), arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserActivityInsightsRow + for rows.Next() { + var i GetUserActivityInsightsRow + if err := rows.Scan( + &i.UserID, + &i.Username, + &i.AvatarURL, + pq.Array(&i.TemplateIDs), + &i.UsageSeconds, + ); 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 getUserLatencyInsights = `-- name: GetUserLatencyInsights :many SELECT workspace_agent_stats.user_id, diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index dc69b69de2eef..7fb48100d5d8a 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -21,6 +21,83 @@ WHERE GROUP BY workspace_agent_stats.user_id, users.username, users.avatar_url ORDER BY user_id ASC; +-- name: GetUserActivityInsights :many +-- GetUserActivityInsights returns the ranking with top active users. +-- The result can be filtered on template_ids, meaning only user data from workspaces +-- based on those templates will be included. +-- Note: When selecting data from multiple templates or the entire deployment, +-- be aware that it may lead to an increase in "usage" numbers (cumulative). In such cases, +-- users may be counted multiple times for the same time interval if they have used multiple templates +-- simultaneously. +WITH app_stats AS ( + SELECT + s.start_time, + was.user_id, + w.template_id, + 60 as seconds + FROM workspace_app_stats was + JOIN workspaces w ON ( + w.id = was.workspace_id + AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN w.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END + ) + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= @start_time::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < (@end_time::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id +), session_stats AS ( + SELECT + date_trunc('minute', was.created_at) as start_time, + was.user_id, + was.template_id, + CASE WHEN + SUM(was.session_count_vscode) > 0 OR + SUM(was.session_count_jetbrains) > 0 OR + SUM(was.session_count_reconnecting_pty) > 0 OR + SUM(was.session_count_ssh) > 0 + THEN 60 ELSE 0 END as seconds + FROM workspace_agent_stats was + WHERE + was.created_at >= @start_time::timestamptz + AND was.created_at < @end_time::timestamptz + AND was.connection_count > 0 + AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END + GROUP BY date_trunc('minute', was.created_at), was.user_id, was.template_id +), combined_stats AS ( + SELECT + user_id, + template_id, + start_time, + seconds + FROM app_stats + UNION + SELECT + user_id, + template_id, + start_time, + seconds + FROM session_stats +) +SELECT + users.id as user_id, + users.username, + users.avatar_url, + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + SUM(seconds) AS usage_seconds +FROM combined_stats +JOIN users ON (users.id = combined_stats.user_id) +GROUP BY users.id, username, avatar_url +ORDER BY user_id ASC; + -- name: GetTemplateInsights :one -- 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 diff --git a/coderd/insights.go b/coderd/insights.go index bb080744c3f7f..a0113b15c131e 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -59,6 +59,93 @@ func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } +// @Summary Get insights about user activity +// @ID get-insights-about-user-activity +// @Security CoderSessionToken +// @Produce json +// @Tags Insights +// @Success 200 {object} codersdk.UserActivityInsightsResponse +// @Router /insights/user-activity [get] +func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + p := httpapi.NewQueryParamParser(). + Required("start_time"). + Required("end_time") + vals := r.URL.Query() + var ( + // The QueryParamParser does not preserve timezone, so we need + // to parse the time ourselves. + startTimeString = p.String(vals, "", "start_time") + endTimeString = p.String(vals, "", "end_time") + templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") + ) + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) + return + } + + startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString) + if !ok { + return + } + + rows, err := api.Database.GetUserActivityInsights(ctx, database.GetUserActivityInsightsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user activity.", + Detail: err.Error(), + }) + return + } + + templateIDSet := make(map[uuid.UUID]struct{}) + userActivities := make([]codersdk.UserActivity, 0, len(rows)) + for _, row := range rows { + for _, templateID := range row.TemplateIDs { + templateIDSet[templateID] = struct{}{} + } + userActivities = append(userActivities, codersdk.UserActivity{ + TemplateIDs: row.TemplateIDs, + UserID: row.UserID, + Username: row.Username, + AvatarURL: row.AvatarURL.String, + Seconds: row.UsageSeconds, + }) + } + + // TemplateIDs that contributed to the data. + seenTemplateIDs := make([]uuid.UUID, 0, len(templateIDSet)) + for templateID := range templateIDSet { + seenTemplateIDs = append(seenTemplateIDs, templateID) + } + slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) + }) + + resp := codersdk.UserActivityInsightsResponse{ + Report: codersdk.UserActivityInsightsReport{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: seenTemplateIDs, + Users: userActivities, + }, + } + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + // @Summary Get insights about user latency // @ID get-insights-about-user-latency // @Security CoderSessionToken diff --git a/coderd/insights_test.go b/coderd/insights_test.go index d9b4832ec8e43..49b7af0841a5a 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -109,6 +109,94 @@ func TestDeploymentInsights(t *testing.T) { require.NoError(t, err) } +func TestUserActivityInsights_SanityCheck(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + }) + + // Create two users, one that will appear in the report and another that + // won't (due to not having/using a workspace). + user := coderdtest.CreateFirstUser(t, client) + _, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart]) + + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Start an agent so that we can generate stats. + _ = agenttest.New(t, client.URL, authToken) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + // Start must be at the beginning of the day, initialize it early in case + // the day changes so that we get the relevant stats faster. + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Connect to the agent to generate usage/latency stats. + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: logger.Named("client"), + }) + require.NoError(t, err) + defer conn.Close() + + sshConn, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshConn.Close() + + sess, err := sshConn.NewSession() + require.NoError(t, err) + defer sess.Close() + + r, w := io.Pipe() + defer r.Close() + defer w.Close() + sess.Stdin = r + sess.Stdout = io.Discard + err = sess.Start("cat") + require.NoError(t, err) + + var userActivities codersdk.UserActivityInsightsResponse + require.Eventuallyf(t, func() bool { + // Keep connection active. + _, err := w.Write([]byte("hello world\n")) + if !assert.NoError(t, err) { + return false + } + userActivities, err = client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{ + StartTime: today, + EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + TemplateIDs: []uuid.UUID{template.ID}, + }) + if !assert.NoError(t, err) { + return false + } + return len(userActivities.Report.Users) > 0 && userActivities.Report.Users[0].Seconds > 0 + }, testutil.WaitMedium, testutil.IntervalFast, "user activity is missing") + + // We got our latency data, close the connection. + _ = sess.Close() + _ = sshConn.Close() + + require.Len(t, userActivities.Report.Users, 1, "want only 1 user") + require.Equal(t, userActivities.Report.Users[0].UserID, user.UserID, "want user id to match") + assert.Greater(t, userActivities.Report.Users[0].Seconds, int64(0), "want usage in seconds to be greater than 0") +} + func TestUserLatencyInsights(t *testing.T) { t.Parallel() @@ -223,6 +311,36 @@ func TestUserLatencyInsights_BadRequest(t *testing.T) { assert.Error(t, err, "want error for end time partial day when not today") } +func TestUserActivityInsights_BadRequest(t *testing.T) { + t.Parallel() + + saoPaulo, err := time.LoadLocation("America/Sao_Paulo") + require.NoError(t, err) + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, saoPaulo) + + // Prepare + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Send insights request + _, err = client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{ + StartTime: today, + EndTime: today.AddDate(0, 0, -1), + }) + assert.Error(t, err, "want error for end time before start time") + + // Send insights request + _, err = client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{ + StartTime: today.AddDate(0, 0, -7), + EndTime: today.Add(-time.Hour), + }) + assert.Error(t, err, "want error for end time partial day when not today") +} + func TestTemplateInsights_Golden(t *testing.T) { t.Parallel() @@ -733,6 +851,7 @@ func TestTemplateInsights_Golden(t *testing.T) { frozenWeekAgoSaoPaulo, err := time.ParseInLocation(time.DateTime, frozenWeekAgo.Format(time.DateTime), saoPaulo) require.NoError(t, err) + //nolint:dupl // For testing purposes makeBaseTestData := func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen { return map[*testWorkspace]testDataGen{ users[0].workspaces[0]: { @@ -1086,7 +1205,7 @@ func TestTemplateInsights_Golden(t *testing.T) { } partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_") - goldenFile := filepath.Join("testdata", "insights", partialName+".json.golden") + goldenFile := filepath.Join("testdata", "insights", "template", partialName+".json.golden") if *updateGoldenFiles { err = os.MkdirAll(filepath.Dir(goldenFile), 0o755) require.NoError(t, err, "want no error creating golden file directory") @@ -1123,153 +1242,816 @@ func TestTemplateInsights_Golden(t *testing.T) { } } -func TestTemplateInsights_BadRequest(t *testing.T) { +func TestUserActivityInsights_Golden(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{}) - _ = coderdtest.CreateFirstUser(t, client) - - y, m, d := time.Now().UTC().Date() - today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: today, - EndTime: today.AddDate(0, 0, -1), - }) - assert.Error(t, err, "want error for end time before start time") + // Prepare test data types. + type templateApp struct { + name string + icon string + } + type testTemplate struct { + name string + apps []templateApp - _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: today.AddDate(0, 0, -7), - EndTime: today.Add(-time.Hour), - }) - assert.Error(t, err, "want error for end time partial day when not today") + // Filled later. + id uuid.UUID + } + type workspaceApp templateApp + type testWorkspace struct { + name string + template *testTemplate - _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: today.AddDate(0, 0, -1), - EndTime: today, - Interval: "invalid", - }) - assert.Error(t, err, "want error for bad interval") + // Filled later. + id uuid.UUID + user any // *testUser, but it's not available yet, defined below. + agentID uuid.UUID + apps []*workspaceApp + agentClient *agentsdk.Client + } + type testUser struct { + name string + workspaces []*testWorkspace - _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: today.AddDate(0, 0, -5), - EndTime: today, - Interval: codersdk.InsightsReportIntervalWeek, - }) - assert.Error(t, err, "last report interval must have at least 6 days") -} + client *codersdk.Client + sdk codersdk.User -func TestTemplateInsights_RBAC(t *testing.T) { - t.Parallel() + // Filled later. + id uuid.UUID + } - y, m, d := time.Now().UTC().Date() - today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + // Represent agent stats, to be inserted via stats batcher. + type agentStat struct { + // Set a range via start/end, multiple stats will be generated + // within the range. + startedAt time.Time + endedAt time.Time - type test struct { - interval codersdk.InsightsReportInterval - withTemplate bool + sessionCountVSCode int64 + sessionCountJetBrains int64 + sessionCountReconnectingPTY int64 + sessionCountSSH int64 + noConnections bool } - - tests := []test{ - {codersdk.InsightsReportIntervalDay, true}, - {codersdk.InsightsReportIntervalDay, false}, - {"", true}, - {"", false}, + // Represent app usage stats, to be inserted via stats reporter. + type appUsage struct { + app *workspaceApp + startedAt time.Time + endedAt time.Time + requests int } - for _, tt := range tests { - tt := tt - - t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) { - t.Parallel() - - t.Run("AsOwner", func(t *testing.T) { - t.Parallel() + // Represent actual data being generated on a per-workspace basis. + type testDataGen struct { + agentStats []agentStat + appUsage []appUsage + } - client := coderdtest.New(t, &coderdtest.Options{}) - admin := coderdtest.CreateFirstUser(t, client) + prepareFixtureAndTestData := func(t *testing.T, makeFixture func() ([]*testTemplate, []*testUser), makeData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen) ([]*testTemplate, []*testUser, map[*testWorkspace]testDataGen) { + var stableIDs []uuid.UUID + newStableUUID := func() uuid.UUID { + stableIDs = append(stableIDs, uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", len(stableIDs)+1))) + stableID := stableIDs[len(stableIDs)-1] + return stableID + } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() + templates, users := makeFixture() + for _, template := range templates { + template.id = newStableUUID() + } + for _, user := range users { + user.id = newStableUUID() + } - var templateIDs []uuid.UUID - if tt.withTemplate { - version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) - templateIDs = append(templateIDs, template.ID) + for _, user := range users { + for _, workspace := range user.workspaces { + workspace.user = user + for _, app := range workspace.template.apps { + app := workspaceApp(app) + workspace.apps = append(workspace.apps, &app) } + } + } - _, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: today.AddDate(0, 0, -1), - EndTime: today, - Interval: tt.interval, - TemplateIDs: templateIDs, - }) - require.NoError(t, err) - }) - t.Run("AsTemplateAdmin", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{}) - admin := coderdtest.CreateFirstUser(t, client) - - templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleTemplateAdmin()) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - - var templateIDs []uuid.UUID - if tt.withTemplate { - version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) - templateIDs = append(templateIDs, template.ID) + testData := makeData(templates, users) + // Sanity check. + for ws, data := range testData { + for _, usage := range data.appUsage { + found := false + for _, app := range ws.apps { + if usage.app == app { // Pointer equality + found = true + break + } + } + if !found { + for _, user := range users { + for _, workspace := range user.workspaces { + for _, app := range workspace.apps { + if usage.app == app { // Pointer equality + require.True(t, found, "test bug: app %q not in workspace %q: want user=%s workspace=%s; got user=%s workspace=%s ", usage.app.name, ws.name, ws.user.(*testUser).name, ws.name, user.name, workspace.name) + break + } + } + } + } + require.True(t, found, "test bug: app %q not in workspace %q", usage.app.name, ws.name) + } + } + } + + return templates, users, testData + } + + prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) *codersdk.Client { + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + Logger: &logger, + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Hour, // Not relevant for this test. + }) + firstUser := coderdtest.CreateFirstUser(t, client) + + // Prepare all test users. + for _, user := range users { + _ = dbgen.User(t, db, database.User{ + ID: user.id, + Username: user.name, + Status: database.UserStatusActive, + }) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.id, + OrganizationID: firstUser.OrganizationID, + }) + token, err := client.CreateToken(context.Background(), user.id.String(), codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24, + Scope: codersdk.APIKeyScopeAll, + TokenName: "no-password-user-token", + }) + require.NoError(t, err) + userClient := codersdk.New(client.URL) + userClient.SetSessionToken(token.Key) + + coderUser, err := userClient.User(context.Background(), user.id.String()) + require.NoError(t, err) + + user.client = userClient + user.sdk = coderUser + + user.client.SetLogger(logger.Named("user").With(slog.Field{Name: "name", Value: user.name})) + } + + // Prepare all the templates. + for _, template := range templates { + template := template + + // Prepare all workspace resources (agents and apps). + var ( + createWorkspaces []func(uuid.UUID) + waitWorkspaces []func() + ) + var resources []*proto.Resource + for _, user := range users { + user := user + for _, workspace := range user.workspaces { + workspace := workspace + + if workspace.template != template { + continue + } + authToken := uuid.New() + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken.String()) + workspace.agentClient = agentClient + + var apps []*proto.App + for _, app := range workspace.apps { + apps = append(apps, &proto.App{ + Slug: app.name, + DisplayName: app.name, + Icon: app.icon, + SharingLevel: proto.AppSharingLevel_OWNER, + Url: "http://", + }) + } + + resources = append(resources, &proto.Resource{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), // Doesn't matter, not used in DB. + Name: "dev", + Auth: &proto.Agent_Token{ + Token: authToken.String(), + }, + Apps: apps, + }}, + }) + + createWorkspaces = append(createWorkspaces, func(templateID uuid.UUID) { + // Create workspace using the users client. + createdWorkspace := coderdtest.CreateWorkspace(t, user.client, firstUser.OrganizationID, templateID) + workspace.id = createdWorkspace.ID + waitWorkspaces = append(waitWorkspaces, func() { + coderdtest.AwaitWorkspaceBuildJob(t, user.client, createdWorkspace.LatestBuild.ID) + ctx := testutil.Context(t, testutil.WaitShort) + ws, err := user.client.Workspace(ctx, workspace.id) + require.NoError(t, err, "want no error getting workspace") + + workspace.agentID = ws.LatestBuild.Resources[0].Agents[0].ID + }) + }) + } + } + + // Create the template version and template. + version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: resources, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // Create template, essentially a modified version of CreateTemplate + // where we can control the template ID. + // createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID) + createdTemplate := dbgen.Template(t, db, database.Template{ + ID: template.id, + ActiveVersionID: version.ID, + OrganizationID: firstUser.OrganizationID, + CreatedBy: firstUser.UserID, + GroupACL: database.TemplateACL{ + firstUser.OrganizationID.String(): []rbac.Action{rbac.ActionRead}, + }, + }) + err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{ + ID: version.ID, + TemplateID: uuid.NullUUID{ + UUID: createdTemplate.ID, + Valid: true, + }, + }) + require.NoError(t, err, "want no error updating template version") + + // Create all workspaces and wait for them. + for _, createWorkspace := range createWorkspaces { + createWorkspace(template.id) + } + for _, waitWorkspace := range waitWorkspaces { + waitWorkspace() + } + } + + ctx := testutil.Context(t, testutil.WaitSuperLong) + + // Use agent stats batcher to insert agent stats, similar to live system. + // NOTE(mafredri): Ideally we would pass batcher as a coderd option and + // insert using the agentClient, but we have a circular dependency on + // the database. + batcher, batcherCloser, err := batchstats.New( + ctx, + batchstats.WithStore(db), + batchstats.WithLogger(logger.Named("batchstats")), + batchstats.WithInterval(time.Hour), + ) + require.NoError(t, err) + defer batcherCloser() // Flushes the stats, this is to ensure they're written. + + for workspace, data := range testData { + for _, stat := range data.agentStats { + createdAt := stat.startedAt + connectionCount := int64(1) + if stat.noConnections { + connectionCount = 0 + } + for createdAt.Before(stat.endedAt) { + err = batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, agentsdk.Stats{ + ConnectionCount: connectionCount, + SessionCountVSCode: stat.sessionCountVSCode, + SessionCountJetBrains: stat.sessionCountJetBrains, + SessionCountReconnectingPTY: stat.sessionCountReconnectingPTY, + SessionCountSSH: stat.sessionCountSSH, + }) + require.NoError(t, err, "want no error inserting agent stats") + createdAt = createdAt.Add(30 * time.Second) + } + } + } + + // Insert app usage. + var stats []workspaceapps.StatsReport + for workspace, data := range testData { + for _, usage := range data.appUsage { + appName := usage.app.name + accessMethod := workspaceapps.AccessMethodPath + if usage.app.name == "terminal" { + appName = "" + accessMethod = workspaceapps.AccessMethodTerminal } + stats = append(stats, workspaceapps.StatsReport{ + UserID: workspace.user.(*testUser).sdk.ID, + WorkspaceID: workspace.id, + AgentID: workspace.agentID, + AccessMethod: accessMethod, + SlugOrPort: appName, + SessionID: uuid.New(), + SessionStartedAt: usage.startedAt, + SessionEndedAt: usage.endedAt, + Requests: usage.requests, + }) + } + } + reporter := workspaceapps.NewStatsDBReporter(db, workspaceapps.DefaultStatsDBReporterBatchSize) + //nolint:gocritic // This is a test. + err = reporter.Report(dbauthz.AsSystemRestricted(ctx), stats) + require.NoError(t, err, "want no error inserting app stats") + + return client + } + + baseTemplateAndUserFixture := func() ([]*testTemplate, []*testUser) { + // Test templates and configuration to generate. + templates := []*testTemplate{ + // Create two templates with near-identical apps and parameters + // to allow testing for grouping similar data. + { + name: "template1", + apps: []templateApp{ + {name: "app1", icon: "/icon1.png"}, + {name: "app2", icon: "/icon2.png"}, + {name: "app3", icon: "/icon2.png"}, + }, + }, + { + name: "template2", + apps: []templateApp{ + {name: "app1", icon: "/icon1.png"}, + {name: "app2", icon: "/icon2.png"}, + {name: "app3", icon: "/icon2.png"}, + }, + }, + // Create another template with different parameters and apps. + { + name: "othertemplate", + apps: []templateApp{ + {name: "otherapp1", icon: "/icon1.png"}, + + // This "special test app" will be converted into web + // terminal usage, this is not included in stats since we + // currently rely on agent stats for this data. + {name: "terminal", icon: "/terminal.png"}, + }, + }, + } + + // Users and workspaces to generate. + users := []*testUser{ + { + name: "user1", + workspaces: []*testWorkspace{ + { + name: "workspace1", + template: templates[0], + }, + { + name: "workspace2", + template: templates[1], + }, + { + name: "otherworkspace3", + template: templates[2], + }, + }, + }, + { + name: "user2", + workspaces: []*testWorkspace{ + { + name: "workspace1", + template: templates[0], + }, + }, + }, + { + name: "user3", + workspaces: []*testWorkspace{ + { + name: "otherworkspace1", + template: templates[2], + }, + { + name: "workspace2", + template: templates[0], + }, + }, + }, + } + + return templates, users + } + + // Time range for report, test data will be generated within and + // outside this range, but only data within the range should be + // included in the report. + frozenLastNight := time.Date(2023, 8, 22, 0, 0, 0, 0, time.UTC) + frozenWeekAgo := frozenLastNight.AddDate(0, 0, -7) + + saoPaulo, err := time.LoadLocation("America/Sao_Paulo") + require.NoError(t, err) + frozenWeekAgoSaoPaulo, err := time.ParseInLocation(time.DateTime, frozenWeekAgo.Format(time.DateTime), saoPaulo) + require.NoError(t, err) + + //nolint:dupl // For testing purposes + makeBaseTestData := func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen { + return map[*testWorkspace]testDataGen{ + users[0].workspaces[0]: { + agentStats: []agentStat{ + { // One hour of usage. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + { // 12 minutes of usage. + startedAt: frozenWeekAgo.AddDate(0, 0, 1), + endedAt: frozenWeekAgo.AddDate(0, 0, 1).Add(12 * time.Minute), + sessionCountSSH: 1, + }, + { // 1m30s of usage -> 2m rounded. + startedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(4*time.Minute + 30*time.Second), + endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Minute), + sessionCountJetBrains: 1, + }, + }, + appUsage: []appUsage{ + { // One hour of usage. + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + requests: 1, + }, + { // 30s of app usage -> 1m rounded. + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo.Add(2*time.Hour + 10*time.Second), + endedAt: frozenWeekAgo.Add(2*time.Hour + 40*time.Second), + requests: 1, + }, + { // 1m30s of app usage -> 2m rounded (included in São Paulo). + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo.Add(3*time.Hour + 30*time.Second), + endedAt: frozenWeekAgo.Add(3*time.Hour + 90*time.Second), + requests: 1, + }, + { // used an app on the last day, counts as active user, 12m. + app: users[0].workspaces[0].apps[2], + startedAt: frozenWeekAgo.AddDate(0, 0, 6), + endedAt: frozenWeekAgo.AddDate(0, 0, 6).Add(12 * time.Minute), + requests: 1, + }, + }, + }, + users[0].workspaces[1]: { + agentStats: []agentStat{ + { + // One hour of usage in second template at the same time + // as in first template. When selecting both templates + // this user and their app usage will only be counted + // once but the template ID will show up in the data. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + { // One hour of usage. + startedAt: frozenWeekAgo.AddDate(0, 0, -12), + endedAt: frozenWeekAgo.AddDate(0, 0, -12).Add(time.Hour), + sessionCountSSH: 1, + sessionCountReconnectingPTY: 1, + }, + { // Another one hour of usage, but "active users" shouldn't be increased twice. + startedAt: frozenWeekAgo.AddDate(0, 0, -10), + endedAt: frozenWeekAgo.AddDate(0, 0, -10).Add(time.Hour), + sessionCountSSH: 1, + sessionCountReconnectingPTY: 1, + }, + }, + appUsage: []appUsage{ + // TODO(mafredri): This doesn't behave correctly right now + // and will add more usage to the app. This could be + // considered both correct and incorrect behavior. + // { // One hour of usage, but same user and same template app, only count once. + // app: users[0].workspaces[1].apps[0], + // startedAt: frozenWeekAgo, + // endedAt: frozenWeekAgo.Add(time.Hour), + // requests: 1, + // }, + { + // Different templates but identical apps, apps will be + // combined and usage will be summed. + app: users[0].workspaces[1].apps[0], + startedAt: frozenWeekAgo.AddDate(0, 0, 2), + endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Hour), + requests: 1, + }, + }, + }, + users[0].workspaces[2]: { + agentStats: []agentStat{}, + appUsage: []appUsage{}, + }, + users[1].workspaces[0]: { + agentStats: []agentStat{ + { // One hour of agent usage before timeframe (exclude). + startedAt: frozenWeekAgo.Add(-time.Hour), + endedAt: frozenWeekAgo, + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + { // One hour of usage. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountSSH: 1, + }, + { // One hour of agent usage after timeframe (exclude in UTC, include in São Paulo). + startedAt: frozenWeekAgo.AddDate(0, 0, 7), + endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour), + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + }, + appUsage: []appUsage{ + { // One hour of app usage before timeframe (exclude). + app: users[1].workspaces[0].apps[2], + startedAt: frozenWeekAgo.Add(-time.Hour), + endedAt: frozenWeekAgo, + requests: 1, + }, + { // One hour of app usage after timeframe (exclude in UTC, include in São Paulo). + app: users[1].workspaces[0].apps[2], + startedAt: frozenWeekAgo.AddDate(0, 0, 7), + endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour), + requests: 1, + }, + }, + }, + users[2].workspaces[0]: { + agentStats: []agentStat{ + { // One hour of usage. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountSSH: 1, + sessionCountReconnectingPTY: 1, + }, + }, + appUsage: []appUsage{ + { + app: users[2].workspaces[0].apps[0], + startedAt: frozenWeekAgo.AddDate(0, 0, 2), + endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(5 * time.Minute), + requests: 1, + }, + { // Special app; excluded from apps, but counted as active during the day. + app: users[2].workspaces[0].apps[1], + startedAt: frozenWeekAgo.AddDate(0, 0, 3), + endedAt: frozenWeekAgo.AddDate(0, 0, 3).Add(5 * time.Minute), + requests: 1, + }, + }, + }, + } + } + type testRequest struct { + name string + makeRequest func([]*testTemplate) codersdk.UserActivityInsightsRequest + ignoreTimes bool + } + tests := []struct { + name string + makeFixture func() ([]*testTemplate, []*testUser) + makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen + requests []testRequest + }{ + { + name: "multiple users and workspaces", + makeFixture: baseTemplateAndUserFixture, + makeTestData: makeBaseTestData, + requests: []testRequest{ + { + name: "week deployment wide", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + } + }, + }, + { + name: "weekly aggregated deployment wide", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + StartTime: frozenWeekAgo.AddDate(0, 0, -3), + EndTime: frozenWeekAgo.AddDate(0, 0, 4), + } + }, + }, + { + name: "week all templates", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + } + }, + }, + { + name: "weekly aggregated templates", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id}, + StartTime: frozenWeekAgo.AddDate(0, 0, -1), + EndTime: frozenWeekAgo.AddDate(0, 0, 6), + } + }, + }, + { + name: "week first template", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[0].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + } + }, + }, + { + name: "weekly aggregated first template", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[0].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + } + }, + }, + { + name: "week second template", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[1].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + } + }, + }, + { + name: "three weeks second template", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[1].id}, + StartTime: frozenWeekAgo.AddDate(0, 0, -14), + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + } + }, + }, + { + name: "week third template", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[2].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + } + }, + }, + { + // São Paulo is three hours behind UTC, so we should not see + // any data between weekAgo and weekAgo.Add(3 * time.Hour). + name: "week other timezone (São Paulo)", + makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest { + return codersdk.UserActivityInsightsRequest{ + StartTime: frozenWeekAgoSaoPaulo, + EndTime: frozenWeekAgoSaoPaulo.AddDate(0, 0, 7), + } + }, + }, + }, + }, + } - _, err := templateAdmin.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: today.AddDate(0, 0, -1), - EndTime: today, - Interval: tt.interval, - TemplateIDs: templateIDs, - }) - require.NoError(t, err) - }) - t.Run("AsRegularUser", func(t *testing.T) { - t.Parallel() + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{}) - admin := coderdtest.CreateFirstUser(t, client) + require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set") + require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set") + templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData) + client := prepare(t, templates, users, testData) - regular, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + for _, req := range tt.requests { + req := req + t.Run(req.name, func(t *testing.T) { + t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() + ctx := testutil.Context(t, testutil.WaitMedium) - var templateIDs []uuid.UUID - if tt.withTemplate { - version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) - templateIDs = append(templateIDs, template.ID) - } + report, err := client.UserActivityInsights(ctx, req.makeRequest(templates)) + require.NoError(t, err, "want no error getting template insights") - _, err := regular.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: today.AddDate(0, 0, -1), - EndTime: today, - Interval: tt.interval, - TemplateIDs: templateIDs, + if req.ignoreTimes { + // Ignore times, we're only interested in the data. + report.Report.StartTime = time.Time{} + report.Report.EndTime = time.Time{} + } + + partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_") + goldenFile := filepath.Join("testdata", "insights", "user-activity", partialName+".json.golden") + if *updateGoldenFiles { + err = os.MkdirAll(filepath.Dir(goldenFile), 0o755) + require.NoError(t, err, "want no error creating golden file directory") + f, err := os.Create(goldenFile) + require.NoError(t, err, "want no error creating golden file") + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + enc.Encode(report) + return + } + + f, err := os.Open(goldenFile) + require.NoError(t, err, "open golden file, run \"make update-golden-files\" and commit the changes") + defer f.Close() + var want codersdk.UserActivityInsightsResponse + err = json.NewDecoder(f).Decode(&want) + require.NoError(t, err, "want no error decoding golden file") + + cmpOpts := []cmp.Option{ + // Ensure readable UUIDs in diff. + cmp.Transformer("UUIDs", func(in []uuid.UUID) (s []string) { + for _, id := range in { + s = append(s, id.String()) + } + return s + }), + } + // Use cmp.Diff here because it produces more readable diffs. + assert.Empty(t, cmp.Diff(want, report, cmpOpts...), "golden file mismatch (-want +got): %s, run \"make update-golden-files\", verify and commit the changes", goldenFile) }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) - }) + } }) } } -func TestUserLatencyInsights_RBAC(t *testing.T) { +func TestTemplateInsights_BadRequest(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today, + EndTime: today.AddDate(0, 0, -1), + }) + assert.Error(t, err, "want error for end time before start time") + + _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -7), + EndTime: today.Add(-time.Hour), + }) + assert.Error(t, err, "want error for end time partial day when not today") + + _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -1), + EndTime: today, + Interval: "invalid", + }) + assert.Error(t, err, "want error for bad interval") + + _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -5), + EndTime: today, + Interval: codersdk.InsightsReportIntervalWeek, + }) + assert.Error(t, err, "last report interval must have at least 6 days") +} + +func TestTemplateInsights_RBAC(t *testing.T) { t.Parallel() y, m, d := time.Now().UTC().Date() @@ -1289,6 +2071,7 @@ func TestUserLatencyInsights_RBAC(t *testing.T) { for _, tt := range tests { tt := tt + t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) { t.Parallel() @@ -1308,9 +2091,10 @@ func TestUserLatencyInsights_RBAC(t *testing.T) { templateIDs = append(templateIDs, template.ID) } - _, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ - StartTime: today, - EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + _, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -1), + EndTime: today, + Interval: tt.interval, TemplateIDs: templateIDs, }) require.NoError(t, err) @@ -1333,9 +2117,10 @@ func TestUserLatencyInsights_RBAC(t *testing.T) { templateIDs = append(templateIDs, template.ID) } - _, err := templateAdmin.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ - StartTime: today, - EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + _, err := templateAdmin.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -1), + EndTime: today, + Interval: tt.interval, TemplateIDs: templateIDs, }) require.NoError(t, err) @@ -1358,9 +2143,10 @@ func TestUserLatencyInsights_RBAC(t *testing.T) { templateIDs = append(templateIDs, template.ID) } - _, err := regular.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ - StartTime: today, - EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + _, err := regular.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -1), + EndTime: today, + Interval: tt.interval, TemplateIDs: templateIDs, }) require.Error(t, err) @@ -1371,3 +2157,127 @@ func TestUserLatencyInsights_RBAC(t *testing.T) { }) } } + +func TestGenericInsights_RBAC(t *testing.T) { + t.Parallel() + + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + type fetchInsightsFunc func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error + + type test struct { + withTemplate bool + } + + endpoints := map[string]fetchInsightsFunc{ + "UserLatency": func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error { + _, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + return err + }, + "UserActivity": func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error { + _, err := client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + return err + }, + } + + for endpointName, endpoint := range endpoints { + endpointName := endpointName + endpoint := endpoint + + t.Run(fmt.Sprintf("With%sEndpoint", endpointName), func(t *testing.T) { + t.Parallel() + + tests := []test{ + {true}, + {false}, + } + + for _, tt := range tests { + tt := tt + + t.Run("AsOwner", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{}) + admin := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + var templateIDs []uuid.UUID + if tt.withTemplate { + version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) + templateIDs = append(templateIDs, template.ID) + } + + err := endpoint(ctx, client, + today, + time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + templateIDs...) + require.NoError(t, err) + }) + t.Run("AsTemplateAdmin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{}) + admin := coderdtest.CreateFirstUser(t, client) + + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + var templateIDs []uuid.UUID + if tt.withTemplate { + version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) + templateIDs = append(templateIDs, template.ID) + } + + err := endpoint(ctx, templateAdmin, + today, + time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + templateIDs...) + require.NoError(t, err) + }) + t.Run("AsRegularUser", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{}) + admin := coderdtest.CreateFirstUser(t, client) + + regular, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + var templateIDs []uuid.UUID + if tt.withTemplate { + version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) + templateIDs = append(templateIDs, template.ID) + } + + err := endpoint(ctx, regular, + today, + time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + templateIDs...) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + } + }) + } +} diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_three_weeks_second_template.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_three_weeks_second_template.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template.json.golden diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_week_all_templates.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_week_all_templates.json.golden diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_week_deployment_wide.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_week_deployment_wide.json.golden diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_week_first_template.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_week_first_template.json.golden diff --git "a/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" "b/coderd/testdata/insights/template/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" similarity index 100% rename from "coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" rename to "coderd/testdata/insights/template/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_second_template.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_week_second_template.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_week_second_template.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_week_second_template.json.golden diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_third_template.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_week_third_template.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_week_third_template.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_week_third_template.json.golden diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_weekly_aggregated_deployment_wide.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_weekly_aggregated_deployment_wide.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_weekly_aggregated_deployment_wide.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_weekly_aggregated_deployment_wide.json.golden diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_weekly_aggregated_first_template.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_weekly_aggregated_first_template.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_weekly_aggregated_first_template.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_weekly_aggregated_first_template.json.golden diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_weekly_aggregated_templates.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_weekly_aggregated_templates.json.golden similarity index 100% rename from coderd/testdata/insights/multiple_users_and_workspaces_weekly_aggregated_templates.json.golden rename to coderd/testdata/insights/template/multiple_users_and_workspaces_weekly_aggregated_templates.json.golden diff --git a/coderd/testdata/insights/parameters_two_days_ago,_no_data.json.golden b/coderd/testdata/insights/template/parameters_two_days_ago,_no_data.json.golden similarity index 100% rename from coderd/testdata/insights/parameters_two_days_ago,_no_data.json.golden rename to coderd/testdata/insights/template/parameters_two_days_ago,_no_data.json.golden diff --git a/coderd/testdata/insights/parameters_yesterday_and_today_deployment_wide.json.golden b/coderd/testdata/insights/template/parameters_yesterday_and_today_deployment_wide.json.golden similarity index 100% rename from coderd/testdata/insights/parameters_yesterday_and_today_deployment_wide.json.golden rename to coderd/testdata/insights/template/parameters_yesterday_and_today_deployment_wide.json.golden diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_three_weeks_second_template.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_three_weeks_second_template.json.golden new file mode 100644 index 0000000000000..d0656f9e9abda --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_three_weeks_second_template.json.golden @@ -0,0 +1,20 @@ +{ + "report": { + "start_time": "2023-08-01T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 32400 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_all_templates.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_all_templates.json.golden new file mode 100644 index 0000000000000..90177a0d6dc4a --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_all_templates.json.golden @@ -0,0 +1,41 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 30540 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000005", + "username": "user2", + "avatar_url": "", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "user_id": "00000000-0000-0000-0000-000000000006", + "username": "user3", + "avatar_url": "", + "seconds": 4200 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_deployment_wide.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_deployment_wide.json.golden new file mode 100644 index 0000000000000..90177a0d6dc4a --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_deployment_wide.json.golden @@ -0,0 +1,41 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 30540 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000005", + "username": "user2", + "avatar_url": "", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "user_id": "00000000-0000-0000-0000-000000000006", + "username": "user3", + "avatar_url": "", + "seconds": 4200 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_first_template.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_first_template.json.golden new file mode 100644 index 0000000000000..d3e9363babadb --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_first_template.json.golden @@ -0,0 +1,29 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 5340 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000005", + "username": "user2", + "avatar_url": "", + "seconds": 3600 + } + ] + } +} diff --git "a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" "b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" new file mode 100644 index 0000000000000..9c4c934feef18 --- /dev/null +++ "b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" @@ -0,0 +1,41 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00-03:00", + "end_time": "2023-08-22T00:00:00-03:00", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 23280 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000005", + "username": "user2", + "avatar_url": "", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "user_id": "00000000-0000-0000-0000-000000000006", + "username": "user3", + "avatar_url": "", + "seconds": 600 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_second_template.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_second_template.json.golden new file mode 100644 index 0000000000000..f7e8c0d09eca5 --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_second_template.json.golden @@ -0,0 +1,20 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 25200 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_third_template.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_third_template.json.golden new file mode 100644 index 0000000000000..065e837723fd3 --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_third_template.json.golden @@ -0,0 +1,20 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "user_id": "00000000-0000-0000-0000-000000000006", + "username": "user3", + "avatar_url": "", + "seconds": 4200 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_deployment_wide.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_deployment_wide.json.golden new file mode 100644 index 0000000000000..458b327c3c392 --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_deployment_wide.json.golden @@ -0,0 +1,41 @@ +{ + "report": { + "start_time": "2023-08-12T00:00:00Z", + "end_time": "2023-08-19T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 29820 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000005", + "username": "user2", + "avatar_url": "", + "seconds": 7200 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "user_id": "00000000-0000-0000-0000-000000000006", + "username": "user3", + "avatar_url": "", + "seconds": 4200 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_first_template.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_first_template.json.golden new file mode 100644 index 0000000000000..d3e9363babadb --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_first_template.json.golden @@ -0,0 +1,29 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 5340 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000005", + "username": "user2", + "avatar_url": "", + "seconds": 3600 + } + ] + } +} diff --git a/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_templates.json.golden b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_templates.json.golden new file mode 100644 index 0000000000000..b91414092f581 --- /dev/null +++ b/coderd/testdata/insights/user-activity/multiple_users_and_workspaces_weekly_aggregated_templates.json.golden @@ -0,0 +1,41 @@ +{ + "report": { + "start_time": "2023-08-14T00:00:00Z", + "end_time": "2023-08-21T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "users": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "user_id": "00000000-0000-0000-0000-000000000004", + "username": "user1", + "avatar_url": "", + "seconds": 29820 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "user_id": "00000000-0000-0000-0000-000000000005", + "username": "user2", + "avatar_url": "", + "seconds": 7200 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "user_id": "00000000-0000-0000-0000-000000000006", + "username": "user3", + "avatar_url": "", + "seconds": 4200 + } + ] + } +} diff --git a/codersdk/insights.go b/codersdk/insights.go index c9ac0ed2deb79..6312c6f2c4a58 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -62,6 +62,30 @@ type UserLatency struct { LatencyMS ConnectionLatency `json:"latency_ms"` } +// UserActivityInsightsResponse is the response from the user activity insights +// endpoint. +type UserActivityInsightsResponse struct { + Report UserActivityInsightsReport `json:"report"` +} + +// UserActivityInsightsReport is the report from the user activity insights +// endpoint. +type UserActivityInsightsReport struct { + StartTime time.Time `json:"start_time" format:"date-time"` + EndTime time.Time `json:"end_time" format:"date-time"` + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + Users []UserActivity `json:"users"` +} + +// UserActivity shows the session time for a user. +type UserActivity struct { + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + UserID uuid.UUID `json:"user_id" format:"uuid"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url" format:"uri"` + Seconds int64 `json:"seconds" example:"80500"` +} + // ConnectionLatency shows the latency for a connection. type ConnectionLatency struct { P50 float64 `json:"p50" example:"31.312"` @@ -100,6 +124,38 @@ func (c *Client) UserLatencyInsights(ctx context.Context, req UserLatencyInsight return result, json.NewDecoder(resp.Body).Decode(&result) } +type UserActivityInsightsRequest struct { + StartTime time.Time `json:"start_time" format:"date-time"` + EndTime time.Time `json:"end_time" format:"date-time"` + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` +} + +func (c *Client) UserActivityInsights(ctx context.Context, req UserActivityInsightsRequest) (UserActivityInsightsResponse, error) { + qp := url.Values{} + qp.Add("start_time", req.StartTime.Format(insightsTimeLayout)) + qp.Add("end_time", req.EndTime.Format(insightsTimeLayout)) + if len(req.TemplateIDs) > 0 { + var templateIDs []string + for _, id := range req.TemplateIDs { + templateIDs = append(templateIDs, id.String()) + } + qp.Add("template_ids", strings.Join(templateIDs, ",")) + } + + reqURL := fmt.Sprintf("/api/v2/insights/user-activity?%s", qp.Encode()) + resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return UserActivityInsightsResponse{}, xerrors.Errorf("make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return UserActivityInsightsResponse{}, ReadBodyAsError(resp) + } + var result UserActivityInsightsResponse + return result, json.NewDecoder(resp.Body).Decode(&result) +} + // TemplateInsightsResponse is the response from the template insights endpoint. type TemplateInsightsResponse struct { Report TemplateInsightsReport `json:"report"` diff --git a/docs/api/insights.md b/docs/api/insights.md index 3c2bb2ba9261b..bfa1fcd380d5d 100644 --- a/docs/api/insights.md +++ b/docs/api/insights.md @@ -115,6 +115,50 @@ curl -X GET http://coder-server:8080/api/v2/insights/templates \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get insights about user activity + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/insights/user-activity \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /insights/user-activity` + +### Example responses + +> 200 Response + +```json +{ + "report": { + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "users": [ + { + "avatar_url": "http://example.com", + "seconds": 80500, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] + } +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserActivityInsightsResponse](schemas.md#codersdkuseractivityinsightsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get insights about user latency ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 176c3bd4cac37..154433f1a7063 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5183,6 +5183,83 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `status` | `active` | | `status` | `suspended` | +## codersdk.UserActivity + +```json +{ + "avatar_url": "http://example.com", + "seconds": 80500, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | --------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `seconds` | integer | false | | | +| `template_ids` | array of string | false | | | +| `user_id` | string | false | | | +| `username` | string | false | | | + +## codersdk.UserActivityInsightsReport + +```json +{ + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "users": [ + { + "avatar_url": "http://example.com", + "seconds": 80500, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------------------------------------------------------- | -------- | ------------ | ----------- | +| `end_time` | string | false | | | +| `start_time` | string | false | | | +| `template_ids` | array of string | false | | | +| `users` | array of [codersdk.UserActivity](#codersdkuseractivity) | false | | | + +## codersdk.UserActivityInsightsResponse + +```json +{ + "report": { + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "users": [ + { + "avatar_url": "http://example.com", + "seconds": 80500, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------- | -------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `report` | [codersdk.UserActivityInsightsReport](#codersdkuseractivityinsightsreport) | false | | | + ## codersdk.UserLatency ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7c10879ebe3ff..6d98abee196bc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1193,6 +1193,35 @@ export interface User { readonly login_type: LoginType; } +// From codersdk/insights.go +export interface UserActivity { + readonly template_ids: string[]; + readonly user_id: string; + readonly username: string; + readonly avatar_url: string; + readonly seconds: number; +} + +// From codersdk/insights.go +export interface UserActivityInsightsReport { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: string[]; + readonly users: UserActivity[]; +} + +// From codersdk/insights.go +export interface UserActivityInsightsRequest { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: string[]; +} + +// From codersdk/insights.go +export interface UserActivityInsightsResponse { + readonly report: UserActivityInsightsReport; +} + // From codersdk/insights.go export interface UserLatency { readonly template_ids: string[];