feat(coderd): add user latency and template insights endpoints (#8519)

Part of #8514
Refs #8109
This commit is contained in:
Mathias Fredriksson 2023-07-21 21:00:19 +03:00 committed by GitHub
parent 539fcf9e6b
commit 30fe153296
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2505 additions and 6 deletions

243
coderd/apidoc/docs.go generated
View File

@ -878,6 +878,56 @@ const docTemplate = `{
}
}
},
"/insights/templates": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Insights"
],
"summary": "Get insights about templates",
"operationId": "get-insights-about-templates",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.TemplateInsightsResponse"
}
}
}
}
},
"/insights/user-latency": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Insights"
],
"summary": "Get insights about user latency",
"operationId": "get-insights-about-user-latency",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserLatencyInsightsResponse"
}
}
}
}
},
"/licenses": {
"get": {
"security": [
@ -6956,6 +7006,19 @@ const docTemplate = `{
"BuildReasonAutostop"
]
},
"codersdk.ConnectionLatency": {
"type": "object",
"properties": {
"p50": {
"type": "number",
"example": 31.312
},
"p95": {
"type": "number",
"example": 119.832
}
}
},
"codersdk.ConvertLoginRequest": {
"type": "object",
"required": [
@ -8040,6 +8103,15 @@ const docTemplate = `{
}
}
},
"codersdk.InsightsReportInterval": {
"type": "string",
"enum": [
"day"
],
"x-enum-varnames": [
"InsightsReportIntervalDay"
]
},
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
"type": "object",
"required": [
@ -9123,6 +9195,50 @@ const docTemplate = `{
}
}
},
"codersdk.TemplateAppUsage": {
"type": "object",
"properties": {
"display_name": {
"type": "string",
"example": "Visual Studio Code"
},
"icon": {
"type": "string"
},
"seconds": {
"type": "integer",
"example": 80500
},
"slug": {
"type": "string",
"example": "vscode"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"type": {
"allOf": [
{
"$ref": "#/definitions/codersdk.TemplateAppsType"
}
],
"example": "builtin"
}
}
},
"codersdk.TemplateAppsType": {
"type": "string",
"enum": [
"builtin"
],
"x-enum-varnames": [
"TemplateAppsTypeBuiltin"
]
},
"codersdk.TemplateBuildTimeStats": {
"type": "object",
"additionalProperties": {
@ -9159,6 +9275,77 @@ const docTemplate = `{
}
}
},
"codersdk.TemplateInsightsIntervalReport": {
"type": "object",
"properties": {
"active_users": {
"type": "integer",
"example": 14
},
"end_time": {
"type": "string",
"format": "date-time"
},
"interval": {
"$ref": "#/definitions/codersdk.InsightsReportInterval"
},
"start_time": {
"type": "string",
"format": "date-time"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"codersdk.TemplateInsightsReport": {
"type": "object",
"properties": {
"active_users": {
"type": "integer",
"example": 22
},
"apps_usage": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.TemplateAppUsage"
}
},
"end_time": {
"type": "string",
"format": "date-time"
},
"start_time": {
"type": "string",
"format": "date-time"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"codersdk.TemplateInsightsResponse": {
"type": "object",
"properties": {
"interval_reports": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.TemplateInsightsIntervalReport"
}
},
"report": {
"$ref": "#/definitions/codersdk.TemplateInsightsReport"
}
}
},
"codersdk.TemplateRestartRequirement": {
"type": "object",
"properties": {
@ -9708,6 +9895,62 @@ const docTemplate = `{
}
}
},
"codersdk.UserLatency": {
"type": "object",
"properties": {
"latency_ms": {
"$ref": "#/definitions/codersdk.ConnectionLatency"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"user_id": {
"type": "string",
"format": "uuid"
},
"username": {
"type": "string"
}
}
},
"codersdk.UserLatencyInsightsReport": {
"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.UserLatency"
}
}
}
},
"codersdk.UserLatencyInsightsResponse": {
"type": "object",
"properties": {
"report": {
"$ref": "#/definitions/codersdk.UserLatencyInsightsReport"
}
}
},
"codersdk.UserLoginType": {
"type": "object",
"properties": {

View File

@ -756,6 +756,48 @@
}
}
},
"/insights/templates": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Insights"],
"summary": "Get insights about templates",
"operationId": "get-insights-about-templates",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.TemplateInsightsResponse"
}
}
}
}
},
"/insights/user-latency": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Insights"],
"summary": "Get insights about user latency",
"operationId": "get-insights-about-user-latency",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserLatencyInsightsResponse"
}
}
}
}
},
"/licenses": {
"get": {
"security": [
@ -6195,6 +6237,19 @@
"BuildReasonAutostop"
]
},
"codersdk.ConnectionLatency": {
"type": "object",
"properties": {
"p50": {
"type": "number",
"example": 31.312
},
"p95": {
"type": "number",
"example": 119.832
}
}
},
"codersdk.ConvertLoginRequest": {
"type": "object",
"required": ["password", "to_type"],
@ -7220,6 +7275,11 @@
}
}
},
"codersdk.InsightsReportInterval": {
"type": "string",
"enum": ["day"],
"x-enum-varnames": ["InsightsReportIntervalDay"]
},
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
"type": "object",
"required": ["agentID", "url"],
@ -8238,6 +8298,46 @@
}
}
},
"codersdk.TemplateAppUsage": {
"type": "object",
"properties": {
"display_name": {
"type": "string",
"example": "Visual Studio Code"
},
"icon": {
"type": "string"
},
"seconds": {
"type": "integer",
"example": 80500
},
"slug": {
"type": "string",
"example": "vscode"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"type": {
"allOf": [
{
"$ref": "#/definitions/codersdk.TemplateAppsType"
}
],
"example": "builtin"
}
}
},
"codersdk.TemplateAppsType": {
"type": "string",
"enum": ["builtin"],
"x-enum-varnames": ["TemplateAppsTypeBuiltin"]
},
"codersdk.TemplateBuildTimeStats": {
"type": "object",
"additionalProperties": {
@ -8274,6 +8374,77 @@
}
}
},
"codersdk.TemplateInsightsIntervalReport": {
"type": "object",
"properties": {
"active_users": {
"type": "integer",
"example": 14
},
"end_time": {
"type": "string",
"format": "date-time"
},
"interval": {
"$ref": "#/definitions/codersdk.InsightsReportInterval"
},
"start_time": {
"type": "string",
"format": "date-time"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"codersdk.TemplateInsightsReport": {
"type": "object",
"properties": {
"active_users": {
"type": "integer",
"example": 22
},
"apps_usage": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.TemplateAppUsage"
}
},
"end_time": {
"type": "string",
"format": "date-time"
},
"start_time": {
"type": "string",
"format": "date-time"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"codersdk.TemplateInsightsResponse": {
"type": "object",
"properties": {
"interval_reports": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.TemplateInsightsIntervalReport"
}
},
"report": {
"$ref": "#/definitions/codersdk.TemplateInsightsReport"
}
}
},
"codersdk.TemplateRestartRequirement": {
"type": "object",
"properties": {
@ -8774,6 +8945,62 @@
}
}
},
"codersdk.UserLatency": {
"type": "object",
"properties": {
"latency_ms": {
"$ref": "#/definitions/codersdk.ConnectionLatency"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"user_id": {
"type": "string",
"format": "uuid"
},
"username": {
"type": "string"
}
}
},
"codersdk.UserLatencyInsightsReport": {
"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.UserLatency"
}
}
}
},
"codersdk.UserLatencyInsightsResponse": {
"type": "object",
"properties": {
"report": {
"$ref": "#/definitions/codersdk.UserLatencyInsightsReport"
}
}
},
"codersdk.UserLoginType": {
"type": "object",
"properties": {

View File

@ -850,6 +850,8 @@ func New(options *Options) *API {
r.Route("/insights", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/daus", api.deploymentDAUs)
r.Get("/user-latency", api.insightsUserLatency)
r.Get("/templates", api.insightsTemplates)
})
r.Route("/debug", func(r chi.Router) {
r.Use(

View File

@ -1173,6 +1173,22 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD
return q.db.GetTemplateDAUs(ctx, arg)
}
func (q *querier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
// FIXME: this should maybe be READ rbac.ResourceTemplate or it's own resource.
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.GetTemplateDailyInsights(ctx, arg)
}
func (q *querier) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
// FIXME: this should maybe be READ rbac.ResourceTemplate or it's own resource.
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return database.GetTemplateInsightsRow{}, err
}
return q.db.GetTemplateInsights(ctx, arg)
}
func (q *querier) GetTemplateVersionByID(ctx context.Context, tvid uuid.UUID) (database.TemplateVersion, error) {
tv, err := q.db.GetTemplateVersionByID(ctx, tvid)
if err != nil {
@ -1339,6 +1355,13 @@ func (q *querier) GetUserCount(ctx context.Context) (int64, error) {
return q.db.GetUserCount(ctx)
}
func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.GetUserLatencyInsights(ctx, arg)
}
func (q *querier) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return database.UserLink{}, err

View File

@ -1944,6 +1944,129 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat
return rs, nil
}
func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
}
type dailyStat struct {
startTime, endTime time.Time
userSet map[uuid.UUID]struct{}
templateIDSet map[uuid.UUID]struct{}
}
dailyStats := []dailyStat{{arg.StartTime, arg.StartTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}}
for dailyStats[len(dailyStats)-1].endTime.Before(arg.EndTime) {
dailyStats = append(dailyStats, dailyStat{dailyStats[len(dailyStats)-1].endTime, dailyStats[len(dailyStats)-1].endTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})})
}
if dailyStats[len(dailyStats)-1].endTime.After(arg.EndTime) {
dailyStats[len(dailyStats)-1].endTime = arg.EndTime
}
for _, s := range q.workspaceAgentStats {
if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) {
continue
}
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
continue
}
if s.ConnectionCount == 0 {
continue
}
for _, ds := range dailyStats {
if s.CreatedAt.Before(ds.startTime) || s.CreatedAt.Equal(ds.endTime) || s.CreatedAt.After(ds.endTime) {
continue
}
ds.userSet[s.UserID] = struct{}{}
ds.templateIDSet[s.TemplateID] = struct{}{}
break
}
}
var result []database.GetTemplateDailyInsightsRow
for _, ds := range dailyStats {
templateIDs := make([]uuid.UUID, 0, len(ds.templateIDSet))
for templateID := range ds.templateIDSet {
templateIDs = append(templateIDs, templateID)
}
slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool {
return a.String() < b.String()
})
result = append(result, database.GetTemplateDailyInsightsRow{
StartTime: ds.startTime,
EndTime: ds.endTime,
TemplateIDs: templateIDs,
ActiveUsers: int64(len(ds.userSet)),
})
}
return result, nil
}
func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.GetTemplateInsightsRow{}, err
}
templateIDSet := make(map[uuid.UUID]struct{})
appUsageIntervalsByUser := make(map[uuid.UUID]map[time.Time]*database.GetTemplateInsightsRow)
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
}
templateIDSet[s.TemplateID] = struct{}{}
if appUsageIntervalsByUser[s.UserID] == nil {
appUsageIntervalsByUser[s.UserID] = make(map[time.Time]*database.GetTemplateInsightsRow)
}
t := s.CreatedAt.Truncate(5 * time.Minute)
if _, ok := appUsageIntervalsByUser[s.UserID][t]; !ok {
appUsageIntervalsByUser[s.UserID][t] = &database.GetTemplateInsightsRow{}
}
if s.SessionCountJetBrains > 0 {
appUsageIntervalsByUser[s.UserID][t].UsageJetbrainsSeconds = 300
}
if s.SessionCountVSCode > 0 {
appUsageIntervalsByUser[s.UserID][t].UsageVscodeSeconds = 300
}
if s.SessionCountReconnectingPTY > 0 {
appUsageIntervalsByUser[s.UserID][t].UsageReconnectingPtySeconds = 300
}
if s.SessionCountSSH > 0 {
appUsageIntervalsByUser[s.UserID][t].UsageSshSeconds = 300
}
}
templateIDs := make([]uuid.UUID, 0, len(templateIDSet))
for templateID := range templateIDSet {
templateIDs = append(templateIDs, templateID)
}
slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool {
return a.String() < b.String()
})
result := database.GetTemplateInsightsRow{
TemplateIDs: templateIDs,
ActiveUsers: int64(len(appUsageIntervalsByUser)),
}
for _, intervals := range appUsageIntervalsByUser {
for _, interval := range intervals {
result.UsageJetbrainsSeconds += interval.UsageJetbrainsSeconds
result.UsageVscodeSeconds += interval.UsageVscodeSeconds
result.UsageReconnectingPtySeconds += interval.UsageReconnectingPtySeconds
result.UsageSshSeconds += interval.UsageSshSeconds
}
}
return result, nil
}
func (q *FakeQuerier) GetTemplateVersionByID(ctx context.Context, templateVersionID uuid.UUID) (database.TemplateVersion, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -2188,6 +2311,74 @@ func (q *FakeQuerier) GetUserCount(_ context.Context) (int64, error) {
return existing, nil
}
func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
}
q.mutex.RLock()
defer q.mutex.RUnlock()
latenciesByUserID := make(map[uuid.UUID][]float64)
seenTemplatesByUserID := make(map[uuid.UUID]map[uuid.UUID]struct{})
for _, s := range q.workspaceAgentStats {
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
continue
}
if !arg.StartTime.Equal(s.CreatedAt) && !(s.CreatedAt.After(arg.StartTime) && s.CreatedAt.Before(arg.EndTime)) {
continue
}
if s.ConnectionCount == 0 {
continue
}
latenciesByUserID[s.UserID] = append(latenciesByUserID[s.UserID], s.ConnectionMedianLatencyMS)
if seenTemplatesByUserID[s.UserID] == nil {
seenTemplatesByUserID[s.UserID] = make(map[uuid.UUID]struct{})
}
seenTemplatesByUserID[s.UserID][s.TemplateID] = struct{}{}
}
tryPercentile := func(fs []float64, p float64) float64 {
if len(fs) == 0 {
return -1
}
sort.Float64s(fs)
return fs[int(float64(len(fs))*p/100)]
}
var rows []database.GetUserLatencyInsightsRow
for userID, latencies := range latenciesByUserID {
sort.Float64s(latencies)
templateIDSet := seenTemplatesByUserID[userID]
templateIDs := make([]uuid.UUID, 0, len(templateIDSet))
for templateID := range templateIDSet {
templateIDs = append(templateIDs, templateID)
}
slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool {
return a.String() < b.String()
})
user, err := q.getUserByIDNoLock(userID)
if err != nil {
return nil, err
}
row := database.GetUserLatencyInsightsRow{
UserID: userID,
Username: user.Username,
TemplateIDs: templateIDs,
WorkspaceConnectionLatency50: tryPercentile(latencies, 50),
WorkspaceConnectionLatency95: tryPercentile(latencies, 95),
}
rows = append(rows, row)
}
slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) bool {
return a.UserID.String() < b.UserID.String()
})
return rows, nil
}
func (q *FakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -5333,9 +5524,9 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
}
}
if len(arg.TemplateIds) > 0 {
if len(arg.TemplateIDs) > 0 {
match := false
for _, id := range arg.TemplateIds {
for _, id := range arg.TemplateIDs {
if workspace.TemplateID == id {
match = true
break

View File

@ -599,6 +599,20 @@ func (m metricsStore) GetTemplateDAUs(ctx context.Context, arg database.GetTempl
return daus, err
}
func (m metricsStore) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
start := time.Now()
r0, r1 := m.s.GetTemplateDailyInsights(ctx, arg)
m.queryLatencies.WithLabelValues("GetTemplateDailyInsights").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
start := time.Now()
r0, r1 := m.s.GetTemplateInsights(ctx, arg)
m.queryLatencies.WithLabelValues("GetTemplateInsights").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (database.TemplateVersion, error) {
start := time.Now()
version, err := m.s.GetTemplateVersionByID(ctx, id)
@ -697,6 +711,13 @@ func (m metricsStore) GetUserCount(ctx context.Context) (int64, error) {
return count, err
}
func (m metricsStore) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserLatencyInsights(ctx, arg)
m.queryLatencies.WithLabelValues("GetUserLatencyInsights").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) {
start := time.Now()
link, err := m.s.GetUserLinkByLinkedID(ctx, linkedID)

View File

@ -1196,6 +1196,21 @@ func (mr *MockStoreMockRecorder) GetTemplateDAUs(arg0, arg1 interface{}) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDAUs", reflect.TypeOf((*MockStore)(nil).GetTemplateDAUs), arg0, arg1)
}
// GetTemplateDailyInsights mocks base method.
func (m *MockStore) GetTemplateDailyInsights(arg0 context.Context, arg1 database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTemplateDailyInsights", arg0, arg1)
ret0, _ := ret[0].([]database.GetTemplateDailyInsightsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTemplateDailyInsights indicates an expected call of GetTemplateDailyInsights.
func (mr *MockStoreMockRecorder) GetTemplateDailyInsights(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDailyInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateDailyInsights), arg0, arg1)
}
// GetTemplateGroupRoles mocks base method.
func (m *MockStore) GetTemplateGroupRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateGroup, error) {
m.ctrl.T.Helper()
@ -1211,6 +1226,21 @@ func (mr *MockStoreMockRecorder) GetTemplateGroupRoles(arg0, arg1 interface{}) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateGroupRoles", reflect.TypeOf((*MockStore)(nil).GetTemplateGroupRoles), arg0, arg1)
}
// GetTemplateInsights mocks base method.
func (m *MockStore) GetTemplateInsights(arg0 context.Context, arg1 database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTemplateInsights", arg0, arg1)
ret0, _ := ret[0].(database.GetTemplateInsightsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTemplateInsights indicates an expected call of GetTemplateInsights.
func (mr *MockStoreMockRecorder) GetTemplateInsights(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateInsights), arg0, arg1)
}
// GetTemplateUserRoles mocks base method.
func (m *MockStore) GetTemplateUserRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateUser, error) {
m.ctrl.T.Helper()
@ -1436,6 +1466,21 @@ func (mr *MockStoreMockRecorder) GetUserCount(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCount", reflect.TypeOf((*MockStore)(nil).GetUserCount), arg0)
}
// GetUserLatencyInsights mocks base method.
func (m *MockStore) GetUserLatencyInsights(arg0 context.Context, arg1 database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserLatencyInsights", arg0, arg1)
ret0, _ := ret[0].([]database.GetUserLatencyInsightsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserLatencyInsights indicates an expected call of GetUserLatencyInsights.
func (mr *MockStoreMockRecorder) GetUserLatencyInsights(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLatencyInsights", reflect.TypeOf((*MockStore)(nil).GetUserLatencyInsights), arg0, arg1)
}
// GetUserLinkByLinkedID mocks base method.
func (m *MockStore) GetUserLinkByLinkedID(arg0 context.Context, arg1 string) (database.UserLink, error) {
m.ctrl.T.Helper()

View File

@ -213,7 +213,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIds),
pq.Array(arg.TemplateIDs),
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,

View File

@ -105,6 +105,14 @@ type sqlcQuerier interface {
GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error)
GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error)
GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error)
// GetTemplateDailyInsights returns all daily intervals between start and end
// time, if end time is a partial day, it will be included in the results and
// that interval will be less than 24 hours. If there is no data for a selected
// interval/template, it will be included in the results with 0 active users.
GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error)
// GetTemplateInsights has a granularity of 5 minutes where if a session/app was
// in use, we will add 5 minutes to the total usage for that session (per user).
GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error)
GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error)
GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error)
GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error)
@ -119,6 +127,11 @@ type sqlcQuerier interface {
GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserCount(ctx context.Context) (int64, error)
// GetUserLatencyInsights returns the median and 95th percentile connection
// latency that users have experienced. The result can be filtered on
// template_ids, meaning only user data from workspaces based on those templates
// will be included.
GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error)
GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error)
GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error)
// This will never return deleted users.

View File

@ -1375,6 +1375,228 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar
return i, err
}
const getTemplateDailyInsights = `-- name: GetTemplateDailyInsights :many
WITH d AS (
-- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series.
SELECT generate_series($1::timestamptz, $2::timestamptz, '1 day'::interval) AS d
), ts AS (
SELECT
d::timestamptz AS from_,
CASE WHEN (d + '1 day'::interval)::timestamptz <= $2::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE $2::timestamptz END AS to_
FROM d
), usage_by_day AS (
SELECT
ts.from_, ts.to_,
was.user_id,
array_agg(was.template_id) AS template_ids
FROM ts
LEFT JOIN workspace_agent_stats was ON (
was.created_at >= ts.from_
AND was.created_at < ts.to_
AND was.connection_count > 0
AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END
)
GROUP BY ts.from_, ts.to_, was.user_id
), template_ids AS (
SELECT array_agg(DISTINCT template_id) AS ids
FROM usage_by_day, unnest(template_ids) template_id
WHERE template_id IS NOT NULL
)
SELECT
from_ AS start_time,
to_ AS end_time,
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
COUNT(DISTINCT user_id) AS active_users
FROM usage_by_day, unnest(template_ids) as template_id
GROUP BY from_, to_
`
type GetTemplateDailyInsightsParams struct {
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
}
type GetTemplateDailyInsightsRow struct {
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
ActiveUsers int64 `db:"active_users" json:"active_users"`
}
// GetTemplateDailyInsights returns all daily intervals between start and end
// time, if end time is a partial day, it will be included in the results and
// that interval will be less than 24 hours. If there is no data for a selected
// interval/template, it will be included in the results with 0 active users.
func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) {
rows, err := q.db.QueryContext(ctx, getTemplateDailyInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs))
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTemplateDailyInsightsRow
for rows.Next() {
var i GetTemplateDailyInsightsRow
if err := rows.Scan(
&i.StartTime,
&i.EndTime,
pq.Array(&i.TemplateIDs),
&i.ActiveUsers,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTemplateInsights = `-- name: GetTemplateInsights :one
WITH d AS (
SELECT generate_series($1::timestamptz, $2::timestamptz, '5 minute'::interval) AS d
), ts AS (
SELECT
d::timestamptz AS from_,
(d + '5 minute'::interval)::timestamptz AS to_,
EXTRACT(epoch FROM '5 minute'::interval) AS seconds
FROM d
), usage_by_user AS (
SELECT
ts.from_,
ts.to_,
was.user_id,
array_agg(was.template_id) AS template_ids,
CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds,
CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds,
CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds,
CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds
FROM ts
JOIN workspace_agent_stats was ON (
was.created_at >= ts.from_
AND was.created_at < ts.to_
AND was.connection_count > 0
AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END
)
GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id
), template_ids AS (
SELECT array_agg(DISTINCT template_id) AS ids
FROM usage_by_user, unnest(template_ids) template_id
WHERE template_id IS NOT NULL
)
SELECT
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
COUNT(DISTINCT user_id) AS active_users,
COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds,
COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds,
COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds,
COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds
FROM usage_by_user
`
type GetTemplateInsightsParams struct {
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
}
type GetTemplateInsightsRow struct {
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
ActiveUsers int64 `db:"active_users" json:"active_users"`
UsageVscodeSeconds int64 `db:"usage_vscode_seconds" json:"usage_vscode_seconds"`
UsageJetbrainsSeconds int64 `db:"usage_jetbrains_seconds" json:"usage_jetbrains_seconds"`
UsageReconnectingPtySeconds int64 `db:"usage_reconnecting_pty_seconds" json:"usage_reconnecting_pty_seconds"`
UsageSshSeconds int64 `db:"usage_ssh_seconds" json:"usage_ssh_seconds"`
}
// GetTemplateInsights has a granularity of 5 minutes where if a session/app was
// in use, we will add 5 minutes to the total usage for that session (per user).
func (q *sqlQuerier) GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) {
row := q.db.QueryRowContext(ctx, getTemplateInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs))
var i GetTemplateInsightsRow
err := row.Scan(
pq.Array(&i.TemplateIDs),
&i.ActiveUsers,
&i.UsageVscodeSeconds,
&i.UsageJetbrainsSeconds,
&i.UsageReconnectingPtySeconds,
&i.UsageSshSeconds,
)
return i, err
}
const getUserLatencyInsights = `-- name: GetUserLatencyInsights :many
SELECT
workspace_agent_stats.user_id,
users.username,
array_agg(DISTINCT template_id)::uuid[] AS template_ids,
coalesce((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_50,
coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95
FROM workspace_agent_stats
JOIN users ON (users.id = workspace_agent_stats.user_id)
WHERE
workspace_agent_stats.created_at >= $1
AND workspace_agent_stats.created_at < $2
AND workspace_agent_stats.connection_median_latency_ms > 0
AND workspace_agent_stats.connection_count > 0
AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN template_id = ANY($3::uuid[]) ELSE TRUE END
GROUP BY workspace_agent_stats.user_id, users.username
ORDER BY user_id ASC
`
type GetUserLatencyInsightsParams struct {
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
}
type GetUserLatencyInsightsRow struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Username string `db:"username" json:"username"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
WorkspaceConnectionLatency50 float64 `db:"workspace_connection_latency_50" json:"workspace_connection_latency_50"`
WorkspaceConnectionLatency95 float64 `db:"workspace_connection_latency_95" json:"workspace_connection_latency_95"`
}
// GetUserLatencyInsights returns the median and 95th percentile connection
// latency that users have experienced. The result can be filtered on
// template_ids, meaning only user data from workspaces based on those templates
// will be included.
func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error) {
rows, err := q.db.QueryContext(ctx, getUserLatencyInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs))
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserLatencyInsightsRow
for rows.Next() {
var i GetUserLatencyInsightsRow
if err := rows.Scan(
&i.UserID,
&i.Username,
pq.Array(&i.TemplateIDs),
&i.WorkspaceConnectionLatency50,
&i.WorkspaceConnectionLatency95,
); 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 deleteLicense = `-- name: DeleteLicense :one
DELETE
FROM licenses
@ -8536,7 +8758,7 @@ type GetWorkspacesParams struct {
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
TemplateName string `db:"template_name" json:"template_name"`
TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
Name string `db:"name" json:"name"`
HasAgent string `db:"has_agent" json:"has_agent"`
AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"`
@ -8571,7 +8793,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIds),
pq.Array(arg.TemplateIDs),
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,

View File

@ -0,0 +1,105 @@
-- name: GetUserLatencyInsights :many
-- GetUserLatencyInsights returns the median and 95th percentile connection
-- latency that users have experienced. The result can be filtered on
-- template_ids, meaning only user data from workspaces based on those templates
-- will be included.
SELECT
workspace_agent_stats.user_id,
users.username,
array_agg(DISTINCT template_id)::uuid[] AS template_ids,
coalesce((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_50,
coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95
FROM workspace_agent_stats
JOIN users ON (users.id = workspace_agent_stats.user_id)
WHERE
workspace_agent_stats.created_at >= @start_time
AND workspace_agent_stats.created_at < @end_time
AND workspace_agent_stats.connection_median_latency_ms > 0
AND workspace_agent_stats.connection_count > 0
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
GROUP BY workspace_agent_stats.user_id, users.username
ORDER BY user_id ASC;
-- name: GetTemplateInsights :one
-- GetTemplateInsights has a granularity of 5 minutes where if a session/app was
-- in use, we will add 5 minutes to the total usage for that session (per user).
WITH d AS (
SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '5 minute'::interval) AS d
), ts AS (
SELECT
d::timestamptz AS from_,
(d + '5 minute'::interval)::timestamptz AS to_,
EXTRACT(epoch FROM '5 minute'::interval) AS seconds
FROM d
), usage_by_user AS (
SELECT
ts.from_,
ts.to_,
was.user_id,
array_agg(was.template_id) AS template_ids,
CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds,
CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds,
CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds,
CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds
FROM ts
JOIN workspace_agent_stats was ON (
was.created_at >= ts.from_
AND was.created_at < ts.to_
AND was.connection_count > 0
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
)
GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id
), template_ids AS (
SELECT array_agg(DISTINCT template_id) AS ids
FROM usage_by_user, unnest(template_ids) template_id
WHERE template_id IS NOT NULL
)
SELECT
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
COUNT(DISTINCT user_id) AS active_users,
COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds,
COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds,
COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds,
COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds
FROM usage_by_user;
-- name: GetTemplateDailyInsights :many
-- GetTemplateDailyInsights returns all daily intervals between start and end
-- time, if end time is a partial day, it will be included in the results and
-- that interval will be less than 24 hours. If there is no data for a selected
-- interval/template, it will be included in the results with 0 active users.
WITH d AS (
-- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series.
SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '1 day'::interval) AS d
), ts AS (
SELECT
d::timestamptz AS from_,
CASE WHEN (d + '1 day'::interval)::timestamptz <= @end_time::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE @end_time::timestamptz END AS to_
FROM d
), usage_by_day AS (
SELECT
ts.*,
was.user_id,
array_agg(was.template_id) AS template_ids
FROM ts
LEFT JOIN workspace_agent_stats was ON (
was.created_at >= ts.from_
AND was.created_at < ts.to_
AND was.connection_count > 0
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
)
GROUP BY ts.from_, ts.to_, was.user_id
), template_ids AS (
SELECT array_agg(DISTINCT template_id) AS ids
FROM usage_by_day, unnest(template_ids) template_id
WHERE template_id IS NOT NULL
)
SELECT
from_ AS start_time,
to_ AS end_time,
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
COUNT(DISTINCT user_id) AS active_users
FROM usage_by_day, unnest(template_ids) as template_id
GROUP BY from_, to_;

View File

@ -69,6 +69,7 @@ overrides:
inactivity_ttl: InactivityTTL
eof: EOF
locked_ttl: LockedTTL
template_ids: TemplateIDs
sql:
- schema: "./dump.sql"

View File

@ -1,13 +1,24 @@
package coderd
import (
"context"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
// Duplicated in codersdk.
const insightsTimeLayout = time.RFC3339
// @Summary Get deployment DAUs
// @ID get-deployment-daus
// @Security CoderSessionToken
@ -43,3 +54,358 @@ func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) {
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get insights about user latency
// @ID get-insights-about-user-latency
// @Security CoderSessionToken
// @Produce json
// @Tags Insights
// @Success 200 {object} codersdk.UserLatencyInsightsResponse
// @Router /insights/user-latency [get]
func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentValues) {
httpapi.Forbidden(rw)
return
}
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.GetUserLatencyInsights(ctx, database.GetUserLatencyInsightsParams{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user latency.",
Detail: err.Error(),
})
return
}
templateIDSet := make(map[uuid.UUID]struct{})
userLatencies := make([]codersdk.UserLatency, 0, len(rows))
for _, row := range rows {
for _, templateID := range row.TemplateIDs {
templateIDSet[templateID] = struct{}{}
}
userLatencies = append(userLatencies, codersdk.UserLatency{
TemplateIDs: row.TemplateIDs,
UserID: row.UserID,
Username: row.Username,
LatencyMS: codersdk.ConnectionLatency{
P50: row.WorkspaceConnectionLatency50,
P95: row.WorkspaceConnectionLatency95,
},
})
}
// 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) bool {
return a.String() < b.String()
})
resp := codersdk.UserLatencyInsightsResponse{
Report: codersdk.UserLatencyInsightsReport{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: seenTemplateIDs,
Users: userLatencies,
},
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get insights about templates
// @ID get-insights-about-templates
// @Security CoderSessionToken
// @Produce json
// @Tags Insights
// @Success 200 {object} codersdk.TemplateInsightsResponse
// @Router /insights/templates [get]
func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentValues) {
httpapi.Forbidden(rw)
return
}
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")
intervalString = p.String(vals, "", "interval")
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
}
interval, ok := verifyInsightsInterval(ctx, rw, intervalString)
if !ok {
return
}
var usage database.GetTemplateInsightsRow
var dailyUsage []database.GetTemplateDailyInsightsRow
// Use a transaction to ensure that we get consistent data between
// the full and interval report.
err := api.Database.InTx(func(db database.Store) error {
var err error
if interval != "" {
dailyUsage, err = db.GetTemplateDailyInsights(ctx, database.GetTemplateDailyInsightsParams{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
if err != nil {
return xerrors.Errorf("get template daily insights: %w", err)
}
}
usage, err = db.GetTemplateInsights(ctx, database.GetTemplateInsightsParams{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
if err != nil {
return xerrors.Errorf("get template insights: %w", err)
}
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching template insights.",
Detail: err.Error(),
})
return
}
resp := codersdk.TemplateInsightsResponse{
Report: codersdk.TemplateInsightsReport{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: usage.TemplateIDs,
ActiveUsers: usage.ActiveUsers,
AppsUsage: convertTemplateInsightsBuiltinApps(usage),
},
IntervalReports: []codersdk.TemplateInsightsIntervalReport{},
}
for _, row := range dailyUsage {
resp.IntervalReports = append(resp.IntervalReports, codersdk.TemplateInsightsIntervalReport{
StartTime: row.StartTime,
EndTime: row.EndTime,
Interval: interval,
TemplateIDs: row.TemplateIDs,
ActiveUsers: row.ActiveUsers,
})
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// convertTemplateInsightsBuiltinApps builds the list of builtin apps from the
// database row, these are apps that are implicitly a part of all templates.
func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) []codersdk.TemplateAppUsage {
return []codersdk.TemplateAppUsage{
{
TemplateIDs: usage.TemplateIDs,
Type: codersdk.TemplateAppsTypeBuiltin,
DisplayName: "Visual Studio Code",
Slug: "vscode",
Icon: "/icons/code.svg",
Seconds: usage.UsageVscodeSeconds,
},
{
TemplateIDs: usage.TemplateIDs,
Type: codersdk.TemplateAppsTypeBuiltin,
DisplayName: "JetBrains",
Slug: "jetbrains",
Icon: "/icons/intellij.svg",
Seconds: usage.UsageJetbrainsSeconds,
},
{
TemplateIDs: usage.TemplateIDs,
Type: codersdk.TemplateAppsTypeBuiltin,
DisplayName: "Web Terminal",
Slug: "reconnecting-pty",
Icon: "/icons/terminal.svg",
Seconds: usage.UsageReconnectingPtySeconds,
},
{
TemplateIDs: usage.TemplateIDs,
Type: codersdk.TemplateAppsTypeBuiltin,
DisplayName: "SSH",
Slug: "ssh",
Icon: "/icons/terminal.svg",
Seconds: usage.UsageSshSeconds,
},
}
}
// parseInsightsStartAndEndTime parses the start and end time query parameters
// and returns the parsed values. The client provided timezone must be preserved
// when parsing the time. Verification is performed so that the start and end
// time are not zero and that the end time is not before the start time. The
// clock must be set to 00:00:00, except for "today", where end time is allowed
// to provide the hour of the day (e.g. 14:00:00).
func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, startTimeString, endTimeString string) (startTime, endTime time.Time, ok bool) {
now := time.Now()
for _, qp := range []struct {
name, value string
dest *time.Time
}{
{"start_time", startTimeString, &startTime},
{"end_time", endTimeString, &endTime},
} {
t, err := time.Parse(insightsTimeLayout, qp.value)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameter has invalid value.",
Validations: []codersdk.ValidationError{
{
Field: qp.name,
Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, insightsTimeLayout, err.Error()),
},
},
})
return time.Time{}, time.Time{}, false
}
if t.IsZero() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameter has invalid value.",
Validations: []codersdk.ValidationError{
{
Field: qp.name,
Detail: fmt.Sprintf("Query param %q must not be zero", qp.name),
},
},
})
return time.Time{}, time.Time{}, false
}
// Round upwards one hour to ensure we can fetch the latest data.
if t.After(now.Truncate(time.Hour).Add(time.Hour)) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameter has invalid value.",
Validations: []codersdk.ValidationError{
{
Field: qp.name,
Detail: fmt.Sprintf("Query param %q must not be in the future", qp.name),
},
},
})
return time.Time{}, time.Time{}, false
}
ensureZeroHour := true
if qp.name == "end_time" {
ey, em, ed := t.Date()
ty, tm, td := now.Date()
ensureZeroHour = ey != ty || em != tm || ed != td
}
h, m, s := t.Clock()
if ensureZeroHour && (h != 0 || m != 0 || s != 0) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameter has invalid value.",
Validations: []codersdk.ValidationError{
{
Field: qp.name,
Detail: fmt.Sprintf("Query param %q must have the clock set to 00:00:00", qp.name),
},
},
})
return time.Time{}, time.Time{}, false
} else if m != 0 || s != 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameter has invalid value.",
Validations: []codersdk.ValidationError{
{
Field: qp.name,
Detail: fmt.Sprintf("Query param %q must have the clock set to %02d:00:00", qp.name, h),
},
},
})
return time.Time{}, time.Time{}, false
}
*qp.dest = t
}
if endTime.Before(startTime) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameter has invalid value.",
Validations: []codersdk.ValidationError{
{
Field: "end_time",
Detail: fmt.Sprintf("Query param %q must be after than %q", "end_time", "start_time"),
},
},
})
return time.Time{}, time.Time{}, false
}
return startTime, endTime, true
}
func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string) (codersdk.InsightsReportInterval, bool) {
switch v := codersdk.InsightsReportInterval(intervalString); v {
case codersdk.InsightsReportIntervalDay, "":
return v, true
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameter has invalid value.",
Validations: []codersdk.ValidationError{
{
Field: "interval",
Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalDay}),
},
},
})
return "", false
}
}

View File

@ -0,0 +1,150 @@
package coderd
import (
"context"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_parseInsightsStartAndEndTime(t *testing.T) {
t.Parallel()
layout := insightsTimeLayout
now := time.Now().UTC()
y, m, d := now.Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
thisHour := time.Date(y, m, d, now.Hour(), 0, 0, 0, time.UTC)
thisHourRoundUp := thisHour.Add(time.Hour)
helsinki, err := time.LoadLocation("Europe/Helsinki")
require.NoError(t, err)
type args struct {
startTime string
endTime string
}
tests := []struct {
name string
args args
wantStartTime time.Time
wantEndTime time.Time
wantOk bool
}{
{
name: "Week",
args: args{
startTime: "2023-07-10T00:00:00Z",
endTime: "2023-07-17T00:00:00Z",
},
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, time.UTC),
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, time.UTC),
wantOk: true,
},
{
name: "Today",
args: args{
startTime: today.Format(layout),
endTime: thisHour.Format(layout),
},
wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC),
wantEndTime: time.Date(2023, 7, today.Day(), thisHour.Hour(), 0, 0, 0, time.UTC),
wantOk: true,
},
{
name: "Today with minutes and seconds",
args: args{
startTime: today.Format(layout),
endTime: thisHour.Add(time.Minute + time.Second).Format(layout),
},
wantOk: false,
},
{
name: "Today (hour round up)",
args: args{
startTime: today.Format(layout),
endTime: thisHourRoundUp.Format(layout),
},
wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC),
wantEndTime: time.Date(2023, 7, today.Day(), thisHourRoundUp.Hour(), 0, 0, 0, time.UTC),
wantOk: true,
},
{
name: "Other timezone week",
args: args{
startTime: "2023-07-10T00:00:00+03:00",
endTime: "2023-07-17T00:00:00+03:00",
},
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, helsinki),
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, helsinki),
wantOk: true,
},
{
name: "Daylight savings time",
args: args{
startTime: "2023-03-26T00:00:00+02:00",
endTime: "2023-03-27T00:00:00+03:00",
},
wantStartTime: time.Date(2023, 3, 26, 0, 0, 0, 0, helsinki),
wantEndTime: time.Date(2023, 3, 27, 0, 0, 0, 0, helsinki),
wantOk: true,
},
{
name: "Bad format",
args: args{
startTime: "2023-07-10",
endTime: "2023-07-17",
},
wantOk: false,
},
{
name: "Zero time",
args: args{
startTime: (time.Time{}).Format(layout),
endTime: (time.Time{}).Format(layout),
},
wantOk: false,
},
{
name: "Time in future",
args: args{
startTime: today.AddDate(0, 0, 1).Format(layout),
endTime: today.AddDate(0, 0, 2).Format(layout),
},
wantOk: false,
},
{
name: "End before start",
args: args{
startTime: today.Format(layout),
endTime: today.AddDate(0, 0, -1).Format(layout),
},
wantOk: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rw := httptest.NewRecorder()
gotStartTime, gotEndTime, gotOk := parseInsightsStartAndEndTime(context.Background(), rw, tt.args.startTime, tt.args.endTime)
if !assert.Equal(t, tt.wantOk, gotOk) {
//nolint:bodyclose
t.Log("Status: ", rw.Result().StatusCode)
t.Log("Body: ", rw.Body.String())
}
// assert.Equal is unable to test time equality with different
// (but same) locations because the *time.Location names differ
// between LoadLocation and Parse, so we use assert.WithinDuration.
assert.WithinDuration(t, tt.wantStartTime, gotStartTime, 0)
assert.True(t, tt.wantStartTime.Equal(gotStartTime))
assert.WithinDuration(t, tt.wantEndTime, gotEndTime, 0)
assert.True(t, tt.wantEndTime.Equal(gotEndTime))
})
}
}

View File

@ -2,12 +2,14 @@ package coderd_test
import (
"context"
"io"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
@ -100,3 +102,275 @@ func TestDeploymentInsights(t *testing.T) {
res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err)
}
func TestUserLatencyInsights(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.ProvisionComplete,
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.
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
agentCloser := agent.New(agent.Options{
Logger: logger.Named("agent"),
Client: agentClient,
})
defer func() {
_ = agentCloser.Close()
}()
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
err = sess.Start("cat")
require.NoError(t, err)
var userLatencies codersdk.UserLatencyInsightsResponse
require.Eventuallyf(t, func() bool {
userLatencies, 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.
TemplateIDs: []uuid.UUID{template.ID},
})
if !assert.NoError(t, err) {
return false
}
return len(userLatencies.Report.Users) > 0 && userLatencies.Report.Users[0].LatencyMS.P50 > 0
}, testutil.WaitShort, testutil.IntervalFast, "user latency is missing")
// We got our latency data, close the connection.
_ = sess.Close()
_ = sshConn.Close()
require.Len(t, userLatencies.Report.Users, 1, "want only 1 user")
require.Equal(t, userLatencies.Report.Users[0].UserID, user.UserID, "want user id to match")
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "want p50 to be greater than 0")
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "want p95 to be greater than 0")
}
func TestUserLatencyInsights_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.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
StartTime: today,
EndTime: today.AddDate(0, 0, -1),
})
assert.Error(t, err, "want error for end time before start time")
_, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
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(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
opts := &coderdtest.Options{
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
}
client := coderdtest.New(t, opts)
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
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.
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
agentCloser := agent.New(agent.Options{
Logger: logger.Named("agent"),
Client: agentClient,
})
defer func() {
_ = agentCloser.Close()
}()
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()
// Start an SSH session to generate SSH usage stats.
sess, err := sshConn.NewSession()
require.NoError(t, err)
defer sess.Close()
r, w := io.Pipe()
defer r.Close()
defer w.Close()
sess.Stdin = r
err = sess.Start("cat")
require.NoError(t, err)
// Start an rpty session to generate rpty usage stats.
rpty, err := client.WorkspaceAgentReconnectingPTY(ctx, codersdk.WorkspaceAgentReconnectingPTYOpts{
AgentID: resources[0].Agents[0].ID,
Reconnect: uuid.New(),
Width: 80,
Height: 24,
})
require.NoError(t, err)
defer rpty.Close()
var resp codersdk.TemplateInsightsResponse
var req codersdk.TemplateInsightsRequest
waitForAppSeconds := func(slug string) func() bool {
return func() bool {
req = codersdk.TemplateInsightsRequest{
StartTime: today,
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour),
Interval: codersdk.InsightsReportIntervalDay,
}
resp, err = client.TemplateInsights(ctx, req)
if !assert.NoError(t, err) {
return false
}
if slices.IndexFunc(resp.Report.AppsUsage, func(au codersdk.TemplateAppUsage) bool {
return au.Slug == slug && au.Seconds > 0
}) != -1 {
return true
}
return false
}
}
require.Eventually(t, waitForAppSeconds("reconnecting-pty"), testutil.WaitShort, testutil.IntervalFast, "reconnecting-pty seconds missing")
require.Eventually(t, waitForAppSeconds("ssh"), testutil.WaitShort, testutil.IntervalFast, "ssh seconds missing")
// We got our data, close down sessions and connections.
_ = rpty.Close()
_ = sess.Close()
_ = sshConn.Close()
assert.WithinDuration(t, req.StartTime, resp.Report.StartTime, 0)
assert.WithinDuration(t, req.EndTime, resp.Report.EndTime, 0)
assert.Equal(t, resp.Report.ActiveUsers, int64(1), "want one active user")
for _, app := range resp.Report.AppsUsage {
if slices.Contains([]string{"reconnecting-pty", "ssh"}, app.Slug) {
assert.Equal(t, app.Seconds, int64(300), "want app %q to have 5 minutes of usage", app.Slug)
} else {
assert.Equal(t, app.Seconds, int64(0), "want app %q to have 0 minutes of usage", app.Slug)
}
}
// The full timeframe is <= 24h, so the interval matches exactly.
assert.Len(t, resp.IntervalReports, 1, "want one interval report")
assert.WithinDuration(t, req.StartTime, resp.IntervalReports[0].StartTime, 0)
assert.WithinDuration(t, req.EndTime, resp.IntervalReports[0].EndTime, 0)
assert.Equal(t, resp.IntervalReports[0].ActiveUsers, int64(1), "want one active user in the interval report")
}
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")
}

View File

@ -69,7 +69,7 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
// return ALL workspaces. Not just workspaces the user can view.
// nolint:gocritic
workspaces, err := api.Database.GetWorkspaces(dbauthz.AsSystemRestricted(ctx), database.GetWorkspacesParams{
TemplateIds: []uuid.UUID{template.ID},
TemplateIDs: []uuid.UUID{template.ID},
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{

189
codersdk/insights.go Normal file
View File

@ -0,0 +1,189 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
// Duplicated in coderd.
const insightsTimeLayout = time.RFC3339
// InsightsReportInterval is the interval of time over which to generate a
// smaller insights report within a time range.
type InsightsReportInterval string
// InsightsReportInterval enums.
const (
InsightsReportIntervalDay InsightsReportInterval = "day"
)
// UserLatencyInsightsResponse is the response from the user latency insights
// endpoint.
type UserLatencyInsightsResponse struct {
Report UserLatencyInsightsReport `json:"report"`
}
// UserLatencyInsightsReport is the report from the user latency insights
// endpoint.
type UserLatencyInsightsReport 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 []UserLatency `json:"users"`
}
// UserLatency shows the connection latency for a user.
type UserLatency struct {
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
UserID uuid.UUID `json:"user_id" format:"uuid"`
Username string `json:"username"`
LatencyMS ConnectionLatency `json:"latency_ms"`
}
// ConnectionLatency shows the latency for a connection.
type ConnectionLatency struct {
P50 float64 `json:"p50" example:"31.312"`
P95 float64 `json:"p95" example:"119.832"`
}
type UserLatencyInsightsRequest 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) UserLatencyInsights(ctx context.Context, req UserLatencyInsightsRequest) (UserLatencyInsightsResponse, error) {
var qp []string
qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout)))
qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout)))
if len(req.TemplateIDs) > 0 {
var templateIDs []string
for _, id := range req.TemplateIDs {
templateIDs = append(templateIDs, id.String())
}
qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ",")))
}
reqURL := fmt.Sprintf("/api/v2/insights/user-latency?%s", strings.Join(qp, "&"))
resp, err := c.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return UserLatencyInsightsResponse{}, xerrors.Errorf("make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return UserLatencyInsightsResponse{}, ReadBodyAsError(resp)
}
var result UserLatencyInsightsResponse
return result, json.NewDecoder(resp.Body).Decode(&result)
}
// TemplateInsightsResponse is the response from the template insights endpoint.
type TemplateInsightsResponse struct {
Report TemplateInsightsReport `json:"report"`
IntervalReports []TemplateInsightsIntervalReport `json:"interval_reports"`
}
// TemplateInsightsReport is the report from the template insights endpoint.
type TemplateInsightsReport 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"`
ActiveUsers int64 `json:"active_users" example:"22"`
AppsUsage []TemplateAppUsage `json:"apps_usage"`
// TODO(mafredri): To be introduced in a future pull request.
// TemplateParametersUsage []TemplateParameterUsage `json:"parameters_usage"`
}
// TemplateInsightsIntervalReport is the report from the template insights
// endpoint for a specific interval.
type TemplateInsightsIntervalReport 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"`
Interval InsightsReportInterval `json:"interval"`
ActiveUsers int64 `json:"active_users" example:"14"`
}
// TemplateAppsType defines the type of app reported.
type TemplateAppsType string
// TemplateAppsType enums.
const (
TemplateAppsTypeBuiltin TemplateAppsType = "builtin"
// TODO(mafredri): To be introduced in a future pull request.
// TemplateAppsTypeApp TemplateAppsType = "app"
)
// TemplateAppUsage shows the usage of an app for one or more templates.
type TemplateAppUsage struct {
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
Type TemplateAppsType `json:"type" example:"builtin"`
DisplayName string `json:"display_name" example:"Visual Studio Code"`
Slug string `json:"slug" example:"vscode"`
Icon string `json:"icon"`
Seconds int64 `json:"seconds" example:"80500"`
}
// TODO(mafredri): To be introduced in a future pull request.
/*
// TemplateParameterUsage shows the usage of a parameter for one or more
// templates.
type TemplateParameterUsage struct {
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Values []TemplateParameterValue `json:"values"`
}
// TemplateParameterValue shows the usage of a parameter value for one or more
// templates.
type TemplateParameterValue struct {
Value *string `json:"value"`
Icon string `json:"icon"`
Count int64 `json:"count"`
}
*/
type TemplateInsightsRequest 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"`
Interval InsightsReportInterval `json:"interval"`
}
func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsRequest) (TemplateInsightsResponse, error) {
var qp []string
qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout)))
qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout)))
if len(req.TemplateIDs) > 0 {
var templateIDs []string
for _, id := range req.TemplateIDs {
templateIDs = append(templateIDs, id.String())
}
qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ",")))
}
if req.Interval != "" {
qp = append(qp, fmt.Sprintf("interval=%s", req.Interval))
}
reqURL := fmt.Sprintf("/api/v2/insights/templates?%s", strings.Join(qp, "&"))
resp, err := c.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return TemplateInsightsResponse{}, xerrors.Errorf("make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return TemplateInsightsResponse{}, ReadBodyAsError(resp)
}
var result TemplateInsightsResponse
return result, json.NewDecoder(resp.Body).Decode(&result)
}

View File

@ -36,3 +36,104 @@ curl -X GET http://coder-server:8080/api/v2/insights/daus \
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DAUsResponse](schemas.md#codersdkdausresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get insights about templates
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/insights/templates \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /insights/templates`
### Example responses
> 200 Response
```json
{
"interval_reports": [
{
"active_users": 14,
"end_time": "2019-08-24T14:15:22Z",
"interval": "day",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
}
],
"report": {
"active_users": 22,
"apps_usage": [
{
"display_name": "Visual Studio Code",
"icon": "string",
"seconds": 80500,
"slug": "vscode",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"type": "builtin"
}
],
"end_time": "2019-08-24T14:15:22Z",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
}
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TemplateInsightsResponse](schemas.md#codersdktemplateinsightsresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get insights about user latency
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/insights/user-latency \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /insights/user-latency`
### 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": [
{
"latency_ms": {
"p50": 31.312,
"p95": 119.832
},
"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.UserLatencyInsightsResponse](schemas.md#codersdkuserlatencyinsightsresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@ -1281,6 +1281,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `autostart` |
| `autostop` |
## codersdk.ConnectionLatency
```json
{
"p50": 31.312,
"p95": 119.832
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ----- | ------ | -------- | ------------ | ----------- |
| `p50` | number | false | | |
| `p95` | number | false | | |
## codersdk.ConvertLoginRequest
```json
@ -2895,6 +2911,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `threshold` | integer | false | | Threshold specifies the number of consecutive failed health checks before returning "unhealthy". |
| `url` | string | false | | URL specifies the endpoint to check for the app health. |
## codersdk.InsightsReportInterval
```json
"day"
```
### Properties
#### Enumerated Values
| Value |
| ----- |
| `day` |
## codersdk.IssueReconnectingPTYSignedTokenRequest
```json
@ -4055,6 +4085,44 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| ------------- | ----------- |
| `provisioner` | `terraform` |
## codersdk.TemplateAppUsage
```json
{
"display_name": "Visual Studio Code",
"icon": "string",
"seconds": 80500,
"slug": "vscode",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"type": "builtin"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------------------------------------------------------ | -------- | ------------ | ----------- |
| `display_name` | string | false | | |
| `icon` | string | false | | |
| `seconds` | integer | false | | |
| `slug` | string | false | | |
| `template_ids` | array of string | false | | |
| `type` | [codersdk.TemplateAppsType](#codersdktemplateappstype) | false | | |
## codersdk.TemplateAppsType
```json
"builtin"
```
### Properties
#### Enumerated Values
| Value |
| --------- |
| `builtin` |
## codersdk.TemplateBuildTimeStats
```json
@ -4102,6 +4170,98 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `tags` | array of string | false | | |
| `url` | string | false | | |
## codersdk.TemplateInsightsIntervalReport
```json
{
"active_users": 14,
"end_time": "2019-08-24T14:15:22Z",
"interval": "day",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------- |
| `active_users` | integer | false | | |
| `end_time` | string | false | | |
| `interval` | [codersdk.InsightsReportInterval](#codersdkinsightsreportinterval) | false | | |
| `start_time` | string | false | | |
| `template_ids` | array of string | false | | |
## codersdk.TemplateInsightsReport
```json
{
"active_users": 22,
"apps_usage": [
{
"display_name": "Visual Studio Code",
"icon": "string",
"seconds": 80500,
"slug": "vscode",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"type": "builtin"
}
],
"end_time": "2019-08-24T14:15:22Z",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | --------------------------------------------------------------- | -------- | ------------ | ----------- |
| `active_users` | integer | false | | |
| `apps_usage` | array of [codersdk.TemplateAppUsage](#codersdktemplateappusage) | false | | |
| `end_time` | string | false | | |
| `start_time` | string | false | | |
| `template_ids` | array of string | false | | |
## codersdk.TemplateInsightsResponse
```json
{
"interval_reports": [
{
"active_users": 14,
"end_time": "2019-08-24T14:15:22Z",
"interval": "day",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
}
],
"report": {
"active_users": 22,
"apps_usage": [
{
"display_name": "Visual Studio Code",
"icon": "string",
"seconds": 80500,
"slug": "vscode",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"type": "builtin"
}
],
"end_time": "2019-08-24T14:15:22Z",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
}
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------------------------------------------------------------------------------------------- | -------- | ------------ | ----------- |
| `interval_reports` | array of [codersdk.TemplateInsightsIntervalReport](#codersdktemplateinsightsintervalreport) | false | | |
| `report` | [codersdk.TemplateInsightsReport](#codersdktemplateinsightsreport) | false | | |
## codersdk.TemplateRestartRequirement
```json
@ -4694,6 +4854,88 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `status` | `active` |
| `status` | `suspended` |
## codersdk.UserLatency
```json
{
"latency_ms": {
"p50": 31.312,
"p95": 119.832
},
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
"username": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | -------------------------------------------------------- | -------- | ------------ | ----------- |
| `latency_ms` | [codersdk.ConnectionLatency](#codersdkconnectionlatency) | false | | |
| `template_ids` | array of string | false | | |
| `user_id` | string | false | | |
| `username` | string | false | | |
## codersdk.UserLatencyInsightsReport
```json
{
"end_time": "2019-08-24T14:15:22Z",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"users": [
{
"latency_ms": {
"p50": 31.312,
"p95": 119.832
},
"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.UserLatency](#codersdkuserlatency) | false | | |
## codersdk.UserLatencyInsightsResponse
```json
{
"report": {
"end_time": "2019-08-24T14:15:22Z",
"start_time": "2019-08-24T14:15:22Z",
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"users": [
{
"latency_ms": {
"p50": 31.312,
"p95": 119.832
},
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
"username": "string"
}
]
}
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------- | ------------------------------------------------------------------------ | -------- | ------------ | ----------- |
| `report` | [codersdk.UserLatencyInsightsReport](#codersdkuserlatencyinsightsreport) | false | | |
## codersdk.UserLoginType
```json

View File

@ -140,6 +140,12 @@ export interface BuildInfoResponse {
readonly workspace_proxy: boolean
}
// From codersdk/insights.go
export interface ConnectionLatency {
readonly p50: number
readonly p95: number
}
// From codersdk/users.go
export interface ConvertLoginRequest {
readonly to_type: LoginType
@ -886,6 +892,16 @@ export interface TemplateACL {
readonly group: TemplateGroup[]
}
// From codersdk/insights.go
export interface TemplateAppUsage {
readonly template_ids: string[]
readonly type: TemplateAppsType
readonly display_name: string
readonly slug: string
readonly icon: string
readonly seconds: number
}
// From codersdk/templates.go
export type TemplateBuildTimeStats = Record<
WorkspaceTransition,
@ -908,6 +924,38 @@ export interface TemplateGroup extends Group {
readonly role: TemplateRole
}
// From codersdk/insights.go
export interface TemplateInsightsIntervalReport {
readonly start_time: string
readonly end_time: string
readonly template_ids: string[]
readonly interval: InsightsReportInterval
readonly active_users: number
}
// From codersdk/insights.go
export interface TemplateInsightsReport {
readonly start_time: string
readonly end_time: string
readonly template_ids: string[]
readonly active_users: number
readonly apps_usage: TemplateAppUsage[]
}
// From codersdk/insights.go
export interface TemplateInsightsRequest {
readonly start_time: string
readonly end_time: string
readonly template_ids: string[]
readonly interval: InsightsReportInterval
}
// From codersdk/insights.go
export interface TemplateInsightsResponse {
readonly report: TemplateInsightsReport
readonly interval_reports: TemplateInsightsIntervalReport[]
}
// From codersdk/templates.go
export interface TemplateRestartRequirement {
readonly days_of_week: string[]
@ -1116,6 +1164,34 @@ export interface User {
readonly avatar_url: string
}
// From codersdk/insights.go
export interface UserLatency {
readonly template_ids: string[]
readonly user_id: string
readonly username: string
readonly latency_ms: ConnectionLatency
}
// From codersdk/insights.go
export interface UserLatencyInsightsReport {
readonly start_time: string
readonly end_time: string
readonly template_ids: string[]
readonly users: UserLatency[]
}
// From codersdk/insights.go
export interface UserLatencyInsightsRequest {
readonly start_time: string
readonly end_time: string
readonly template_ids: string[]
}
// From codersdk/insights.go
export interface UserLatencyInsightsResponse {
readonly report: UserLatencyInsightsReport
}
// From codersdk/users.go
export interface UserLoginType {
readonly login_type: LoginType
@ -1513,6 +1589,10 @@ export const GitProviders: GitProvider[] = [
"gitlab",
]
// From codersdk/insights.go
export type InsightsReportInterval = "day"
export const InsightsReportIntervals: InsightsReportInterval[] = ["day"]
// From codersdk/provisionerdaemons.go
export type JobErrorCode =
| "MISSING_TEMPLATE_PARAMETER"
@ -1664,6 +1744,10 @@ export const ServerSentEventTypes: ServerSentEventType[] = [
"ping",
]
// From codersdk/insights.go
export type TemplateAppsType = "builtin"
export const TemplateAppsTypes: TemplateAppsType[] = ["builtin"]
// From codersdk/templates.go
export type TemplateRole = "" | "admin" | "use"
export const TemplateRoles: TemplateRole[] = ["", "admin", "use"]