mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: expose insights into user activity (#9807)
This commit is contained in:
Generated
+86
@@ -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": {
|
||||
|
||||
Generated
+82
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+980
-70
File diff suppressed because it is too large
Load Diff
+20
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+41
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_first_template.json.golden
Vendored
+29
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
coderd/testdata/insights/user-activity/multiple_users_and_workspaces_week_third_template.json.golden
Vendored
+20
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user