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
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{