Skip to content

Commit 4c3b579

Browse files
authored
feat: expose insights into user activity (#9807)
1 parent 1f43357 commit 4c3b579

File tree

38 files changed

+2207
-129
lines changed

38 files changed

+2207
-129
lines changed

coderd/apidoc/docs.go

Lines changed: 86 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 82 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,7 @@ func New(options *Options) *API {
895895
r.Route("/insights", func(r chi.Router) {
896896
r.Use(apiKeyMiddleware)
897897
r.Get("/daus", api.deploymentDAUs)
898+
r.Get("/user-activity", api.insightsUserActivity)
898899
r.Get("/user-latency", api.insightsUserLatency)
899900
r.Get("/templates", api.insightsTemplates)
900901
})

coderd/database/dbauthz/dbauthz.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,25 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
14781478
return q.db.GetUnexpiredLicenses(ctx)
14791479
}
14801480

1481+
func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
1482+
for _, templateID := range arg.TemplateIDs {
1483+
template, err := q.db.GetTemplateByID(ctx, templateID)
1484+
if err != nil {
1485+
return nil, err
1486+
}
1487+
1488+
if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil {
1489+
return nil, err
1490+
}
1491+
}
1492+
if len(arg.TemplateIDs) == 0 {
1493+
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil {
1494+
return nil, err
1495+
}
1496+
}
1497+
return q.db.GetUserActivityInsights(ctx, arg)
1498+
}
1499+
14811500
func (q *querier) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
14821501
return fetch(q.log, q.auth, q.db.GetUserByEmailOrUsername)(ctx, arg)
14831502
}

coderd/database/dbfake/dbfake.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2831,6 +2831,142 @@ func (q *FakeQuerier) GetUnexpiredLicenses(_ context.Context) ([]database.Licens
28312831
return results, nil
28322832
}
28332833

2834+
func (q *FakeQuerier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
2835+
err := validateDatabaseType(arg)
2836+
if err != nil {
2837+
return nil, err
2838+
}
2839+
2840+
q.mutex.RLock()
2841+
defer q.mutex.RUnlock()
2842+
2843+
type uniqueKey struct {
2844+
TemplateID uuid.UUID
2845+
UserID uuid.UUID
2846+
}
2847+
2848+
combinedStats := make(map[uniqueKey]map[time.Time]int64)
2849+
2850+
// Get application stats
2851+
for _, s := range q.workspaceAppStats {
2852+
if !(((s.SessionStartedAt.After(arg.StartTime) || s.SessionStartedAt.Equal(arg.StartTime)) && s.SessionStartedAt.Before(arg.EndTime)) ||
2853+
(s.SessionEndedAt.After(arg.StartTime) && s.SessionEndedAt.Before(arg.EndTime)) ||
2854+
(s.SessionStartedAt.Before(arg.StartTime) && (s.SessionEndedAt.After(arg.EndTime) || s.SessionEndedAt.Equal(arg.EndTime)))) {
2855+
continue
2856+
}
2857+
2858+
w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID)
2859+
if err != nil {
2860+
return nil, err
2861+
}
2862+
2863+
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, w.TemplateID) {
2864+
continue
2865+
}
2866+
2867+
key := uniqueKey{
2868+
TemplateID: w.TemplateID,
2869+
UserID: s.UserID,
2870+
}
2871+
if combinedStats[key] == nil {
2872+
combinedStats[key] = make(map[time.Time]int64)
2873+
}
2874+
2875+
t := s.SessionStartedAt.Truncate(time.Minute)
2876+
if t.Before(arg.StartTime) {
2877+
t = arg.StartTime
2878+
}
2879+
for t.Before(s.SessionEndedAt) && t.Before(arg.EndTime) {
2880+
combinedStats[key][t] = 60
2881+
t = t.Add(1 * time.Minute)
2882+
}
2883+
}
2884+
2885+
// Get session stats
2886+
for _, s := range q.workspaceAgentStats {
2887+
if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) {
2888+
continue
2889+
}
2890+
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
2891+
continue
2892+
}
2893+
if s.ConnectionCount == 0 {
2894+
continue
2895+
}
2896+
2897+
key := uniqueKey{
2898+
TemplateID: s.TemplateID,
2899+
UserID: s.UserID,
2900+
}
2901+
2902+
if combinedStats[key] == nil {
2903+
combinedStats[key] = make(map[time.Time]int64)
2904+
}
2905+
2906+
if s.SessionCountJetBrains > 0 || s.SessionCountVSCode > 0 || s.SessionCountReconnectingPTY > 0 || s.SessionCountSSH > 0 {
2907+
t := s.CreatedAt.Truncate(time.Minute)
2908+
combinedStats[key][t] = 60
2909+
}
2910+
}
2911+
2912+
// Use temporary maps for aggregation purposes
2913+
mUserIDTemplateIDs := map[uuid.UUID]map[uuid.UUID]struct{}{}
2914+
mUserIDUsageSeconds := map[uuid.UUID]int64{}
2915+
2916+
for key, times := range combinedStats {
2917+
if mUserIDTemplateIDs[key.UserID] == nil {
2918+
mUserIDTemplateIDs[key.UserID] = make(map[uuid.UUID]struct{})
2919+
mUserIDUsageSeconds[key.UserID] = 0
2920+
}
2921+
2922+
if _, ok := mUserIDTemplateIDs[key.UserID][key.TemplateID]; !ok {
2923+
mUserIDTemplateIDs[key.UserID][key.TemplateID] = struct{}{}
2924+
}
2925+
2926+
for _, t := range times {
2927+
mUserIDUsageSeconds[key.UserID] += t
2928+
}
2929+
}
2930+
2931+
userIDs := make([]uuid.UUID, 0, len(mUserIDUsageSeconds))
2932+
for userID := range mUserIDUsageSeconds {
2933+
userIDs = append(userIDs, userID)
2934+
}
2935+
sort.Slice(userIDs, func(i, j int) bool {
2936+
return userIDs[i].String() < userIDs[j].String()
2937+
})
2938+
2939+
// Finally, select stats
2940+
var rows []database.GetUserActivityInsightsRow
2941+
2942+
for _, userID := range userIDs {
2943+
user, err := q.getUserByIDNoLock(userID)
2944+
if err != nil {
2945+
return nil, err
2946+
}
2947+
2948+
tids := mUserIDTemplateIDs[userID]
2949+
templateIDs := make([]uuid.UUID, 0, len(tids))
2950+
for key := range tids {
2951+
templateIDs = append(templateIDs, key)
2952+
}
2953+
sort.Slice(templateIDs, func(i, j int) bool {
2954+
return templateIDs[i].String() < templateIDs[j].String()
2955+
})
2956+
2957+
row := database.GetUserActivityInsightsRow{
2958+
UserID: user.ID,
2959+
Username: user.Username,
2960+
AvatarURL: user.AvatarURL,
2961+
TemplateIDs: templateIDs,
2962+
UsageSeconds: mUserIDUsageSeconds[userID],
2963+
}
2964+
2965+
rows = append(rows, row)
2966+
}
2967+
return rows, nil
2968+
}
2969+
28342970
func (q *FakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
28352971
if err := validateDatabaseType(arg); err != nil {
28362972
return database.User{}, err

coderd/database/dbmetrics/dbmetrics.go

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)