mirror of https://github.com/coder/coder.git
feat: expose app insights as Prometheus metrics (#10346)
This commit is contained in:
parent
8441c36dfb
commit
0a550815e9
|
@ -1265,6 +1265,13 @@ func (q *querier) GetTemplateAppInsights(ctx context.Context, arg database.GetTe
|
|||
return q.db.GetTemplateAppInsights(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetTemplateAppInsightsByTemplate(ctx context.Context, arg database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetTemplateAppInsightsByTemplate(ctx, arg)
|
||||
}
|
||||
|
||||
// Only used by metrics cache.
|
||||
func (q *querier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
|
|
|
@ -2365,6 +2365,106 @@ func (q *FakeQuerier) GetTemplateAppInsights(ctx context.Context, arg database.G
|
|||
return rows, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetTemplateAppInsightsByTemplate(ctx context.Context, arg database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
type uniqueKey struct {
|
||||
TemplateID uuid.UUID
|
||||
DisplayName string
|
||||
Slug string
|
||||
}
|
||||
|
||||
// map (TemplateID + DisplayName + Slug) x time.Time x UserID x <usage>
|
||||
usageByTemplateAppUser := map[uniqueKey]map[time.Time]map[uuid.UUID]int64{}
|
||||
|
||||
// Review agent stats in terms of usage
|
||||
for _, s := range q.workspaceAppStats {
|
||||
// (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_)
|
||||
// OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_)
|
||||
// OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_)
|
||||
if !(((s.SessionStartedAt.After(arg.StartTime) || s.SessionStartedAt.Equal(arg.StartTime)) && s.SessionStartedAt.Before(arg.EndTime)) ||
|
||||
(s.SessionEndedAt.After(arg.StartTime) && s.SessionEndedAt.Before(arg.EndTime)) ||
|
||||
(s.SessionStartedAt.Before(arg.StartTime) && (s.SessionEndedAt.After(arg.EndTime) || s.SessionEndedAt.Equal(arg.EndTime)))) {
|
||||
continue
|
||||
}
|
||||
|
||||
w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
app, _ := q.getWorkspaceAppByAgentIDAndSlugNoLock(ctx, database.GetWorkspaceAppByAgentIDAndSlugParams{
|
||||
AgentID: s.AgentID,
|
||||
Slug: s.SlugOrPort,
|
||||
})
|
||||
|
||||
key := uniqueKey{
|
||||
TemplateID: w.TemplateID,
|
||||
DisplayName: app.DisplayName,
|
||||
Slug: app.Slug,
|
||||
}
|
||||
|
||||
t := s.SessionStartedAt.Truncate(time.Minute)
|
||||
if t.Before(arg.StartTime) {
|
||||
t = arg.StartTime
|
||||
}
|
||||
for t.Before(s.SessionEndedAt) && t.Before(arg.EndTime) {
|
||||
if _, ok := usageByTemplateAppUser[key]; !ok {
|
||||
usageByTemplateAppUser[key] = map[time.Time]map[uuid.UUID]int64{}
|
||||
}
|
||||
if _, ok := usageByTemplateAppUser[key][t]; !ok {
|
||||
usageByTemplateAppUser[key][t] = map[uuid.UUID]int64{}
|
||||
}
|
||||
if _, ok := usageByTemplateAppUser[key][t][s.UserID]; !ok {
|
||||
usageByTemplateAppUser[key][t][s.UserID] = 60 // 1 minute
|
||||
}
|
||||
t = t.Add(1 * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort usage data
|
||||
usageKeys := make([]uniqueKey, len(usageByTemplateAppUser))
|
||||
var i int
|
||||
for key := range usageByTemplateAppUser {
|
||||
usageKeys[i] = key
|
||||
i++
|
||||
}
|
||||
|
||||
slices.SortFunc(usageKeys, func(a, b uniqueKey) int {
|
||||
if a.TemplateID != b.TemplateID {
|
||||
return slice.Ascending(a.TemplateID.String(), b.TemplateID.String())
|
||||
}
|
||||
if a.DisplayName != b.DisplayName {
|
||||
return slice.Ascending(a.DisplayName, b.DisplayName)
|
||||
}
|
||||
return slice.Ascending(a.Slug, b.Slug)
|
||||
})
|
||||
|
||||
// Build result
|
||||
var result []database.GetTemplateAppInsightsByTemplateRow
|
||||
for _, usageKey := range usageKeys {
|
||||
r := database.GetTemplateAppInsightsByTemplateRow{
|
||||
TemplateID: usageKey.TemplateID,
|
||||
DisplayName: sql.NullString{String: usageKey.DisplayName, Valid: true},
|
||||
SlugOrPort: usageKey.Slug,
|
||||
}
|
||||
for _, mUserUsage := range usageByTemplateAppUser[usageKey] {
|
||||
r.ActiveUsers += int64(len(mUserUsage))
|
||||
for _, usage := range mUserUsage {
|
||||
r.UsageSeconds += usage
|
||||
}
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.GetTemplateAverageBuildTimeRow{}, err
|
||||
|
|
|
@ -662,6 +662,13 @@ func (m metricsStore) GetTemplateAppInsights(ctx context.Context, arg database.G
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetTemplateAppInsightsByTemplate(ctx context.Context, arg database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTemplateAppInsightsByTemplate(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetTemplateAppInsightsByTemplate").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) {
|
||||
start := time.Now()
|
||||
buildTime, err := m.s.GetTemplateAverageBuildTime(ctx, arg)
|
||||
|
|
|
@ -1328,6 +1328,21 @@ func (mr *MockStoreMockRecorder) GetTemplateAppInsights(arg0, arg1 interface{})
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateAppInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateAppInsights), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetTemplateAppInsightsByTemplate mocks base method.
|
||||
func (m *MockStore) GetTemplateAppInsightsByTemplate(arg0 context.Context, arg1 database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTemplateAppInsightsByTemplate", arg0, arg1)
|
||||
ret0, _ := ret[0].([]database.GetTemplateAppInsightsByTemplateRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTemplateAppInsightsByTemplate indicates an expected call of GetTemplateAppInsightsByTemplate.
|
||||
func (mr *MockStoreMockRecorder) GetTemplateAppInsightsByTemplate(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateAppInsightsByTemplate", reflect.TypeOf((*MockStore)(nil).GetTemplateAppInsightsByTemplate), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetTemplateAverageBuildTime mocks base method.
|
||||
func (m *MockStore) GetTemplateAverageBuildTime(arg0 context.Context, arg1 database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
@ -132,6 +132,7 @@ type sqlcQuerier interface {
|
|||
// timeframe. The result can be filtered on template_ids, meaning only user data
|
||||
// from workspaces based on those templates will be included.
|
||||
GetTemplateAppInsights(ctx context.Context, arg GetTemplateAppInsightsParams) ([]GetTemplateAppInsightsRow, error)
|
||||
GetTemplateAppInsightsByTemplate(ctx context.Context, arg GetTemplateAppInsightsByTemplateParams) ([]GetTemplateAppInsightsByTemplateRow, error)
|
||||
GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error)
|
||||
GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error)
|
||||
GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error)
|
||||
|
|
|
@ -1757,6 +1757,96 @@ func (q *sqlQuerier) GetTemplateAppInsights(ctx context.Context, arg GetTemplate
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const getTemplateAppInsightsByTemplate = `-- name: GetTemplateAppInsightsByTemplate :many
|
||||
WITH app_stats_by_user_and_agent AS (
|
||||
SELECT
|
||||
s.start_time,
|
||||
60 as seconds,
|
||||
w.template_id,
|
||||
was.user_id,
|
||||
was.agent_id,
|
||||
was.slug_or_port,
|
||||
wa.display_name,
|
||||
(wa.slug IS NOT NULL)::boolean AS is_app
|
||||
FROM workspace_app_stats was
|
||||
JOIN workspaces w ON (
|
||||
w.id = was.workspace_id
|
||||
)
|
||||
-- We do a left join here because we want to include user IDs that have used
|
||||
-- e.g. ports when counting active users.
|
||||
LEFT JOIN workspace_apps wa ON (
|
||||
wa.agent_id = was.agent_id
|
||||
AND wa.slug = was.slug_or_port
|
||||
)
|
||||
-- This table contains both 1 minute entries and >1 minute entries,
|
||||
-- to calculate this with our uniqueness constraints, we generate series
|
||||
-- for the longer intervals.
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
date_trunc('minute', was.session_started_at),
|
||||
-- Subtract 1 microsecond to avoid creating an extra series.
|
||||
date_trunc('minute', was.session_ended_at - '1 microsecond'::interval),
|
||||
'1 minute'::interval
|
||||
) s(start_time)
|
||||
WHERE
|
||||
s.start_time >= $1::timestamptz
|
||||
-- Subtract one minute because the series only contains the start time.
|
||||
AND s.start_time < ($2::timestamptz) - '1 minute'::interval
|
||||
GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.slug_or_port, wa.display_name, wa.slug
|
||||
)
|
||||
|
||||
SELECT
|
||||
template_id,
|
||||
display_name,
|
||||
slug_or_port,
|
||||
COALESCE(COUNT(DISTINCT user_id))::bigint AS active_users,
|
||||
SUM(seconds) AS usage_seconds
|
||||
FROM app_stats_by_user_and_agent
|
||||
WHERE is_app IS TRUE
|
||||
GROUP BY template_id, display_name, slug_or_port
|
||||
`
|
||||
|
||||
type GetTemplateAppInsightsByTemplateParams struct {
|
||||
StartTime time.Time `db:"start_time" json:"start_time"`
|
||||
EndTime time.Time `db:"end_time" json:"end_time"`
|
||||
}
|
||||
|
||||
type GetTemplateAppInsightsByTemplateRow struct {
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
DisplayName sql.NullString `db:"display_name" json:"display_name"`
|
||||
SlugOrPort string `db:"slug_or_port" json:"slug_or_port"`
|
||||
ActiveUsers int64 `db:"active_users" json:"active_users"`
|
||||
UsageSeconds int64 `db:"usage_seconds" json:"usage_seconds"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetTemplateAppInsightsByTemplate(ctx context.Context, arg GetTemplateAppInsightsByTemplateParams) ([]GetTemplateAppInsightsByTemplateRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTemplateAppInsightsByTemplate, arg.StartTime, arg.EndTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTemplateAppInsightsByTemplateRow
|
||||
for rows.Next() {
|
||||
var i GetTemplateAppInsightsByTemplateRow
|
||||
if err := rows.Scan(
|
||||
&i.TemplateID,
|
||||
&i.DisplayName,
|
||||
&i.SlugOrPort,
|
||||
&i.ActiveUsers,
|
||||
&i.UsageSeconds,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getTemplateInsights = `-- name: GetTemplateInsights :one
|
||||
WITH agent_stats_by_interval_and_user AS (
|
||||
SELECT
|
||||
|
|
|
@ -218,6 +218,53 @@ SELECT
|
|||
FROM app_stats_by_user_and_agent
|
||||
GROUP BY access_method, slug_or_port, display_name, icon, is_app;
|
||||
|
||||
-- name: GetTemplateAppInsightsByTemplate :many
|
||||
WITH app_stats_by_user_and_agent AS (
|
||||
SELECT
|
||||
s.start_time,
|
||||
60 as seconds,
|
||||
w.template_id,
|
||||
was.user_id,
|
||||
was.agent_id,
|
||||
was.slug_or_port,
|
||||
wa.display_name,
|
||||
(wa.slug IS NOT NULL)::boolean AS is_app
|
||||
FROM workspace_app_stats was
|
||||
JOIN workspaces w ON (
|
||||
w.id = was.workspace_id
|
||||
)
|
||||
-- We do a left join here because we want to include user IDs that have used
|
||||
-- e.g. ports when counting active users.
|
||||
LEFT JOIN workspace_apps wa ON (
|
||||
wa.agent_id = was.agent_id
|
||||
AND wa.slug = was.slug_or_port
|
||||
)
|
||||
-- This table contains both 1 minute entries and >1 minute entries,
|
||||
-- to calculate this with our uniqueness constraints, we generate series
|
||||
-- for the longer intervals.
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
date_trunc('minute', was.session_started_at),
|
||||
-- Subtract 1 microsecond to avoid creating an extra series.
|
||||
date_trunc('minute', was.session_ended_at - '1 microsecond'::interval),
|
||||
'1 minute'::interval
|
||||
) s(start_time)
|
||||
WHERE
|
||||
s.start_time >= @start_time::timestamptz
|
||||
-- Subtract one minute because the series only contains the start time.
|
||||
AND s.start_time < (@end_time::timestamptz) - '1 minute'::interval
|
||||
GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.slug_or_port, wa.display_name, wa.slug
|
||||
)
|
||||
|
||||
SELECT
|
||||
template_id,
|
||||
display_name,
|
||||
slug_or_port,
|
||||
COALESCE(COUNT(DISTINCT user_id))::bigint AS active_users,
|
||||
SUM(seconds) AS usage_seconds
|
||||
FROM app_stats_by_user_and_agent
|
||||
WHERE is_app IS TRUE
|
||||
GROUP BY template_id, display_name, slug_or_port;
|
||||
|
||||
-- name: GetTemplateInsightsByInterval :many
|
||||
-- GetTemplateInsightsByInterval returns all intervals between start and end
|
||||
-- time, if end time is a partial interval, it will be included in the results and
|
||||
|
|
|
@ -452,7 +452,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage
|
|||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "Visual Studio Code",
|
||||
DisplayName: codersdk.TemplateBuiltinAppDisplayNameVSCode,
|
||||
Slug: "vscode",
|
||||
Icon: "/icon/code.svg",
|
||||
Seconds: usage.UsageVscodeSeconds,
|
||||
|
@ -460,7 +460,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage
|
|||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "JetBrains",
|
||||
DisplayName: codersdk.TemplateBuiltinAppDisplayNameJetBrains,
|
||||
Slug: "jetbrains",
|
||||
Icon: "/icon/intellij.svg",
|
||||
Seconds: usage.UsageJetbrainsSeconds,
|
||||
|
@ -474,7 +474,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage
|
|||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "Web Terminal",
|
||||
DisplayName: codersdk.TemplateBuiltinAppDisplayNameWebTerminal,
|
||||
Slug: "reconnecting-pty",
|
||||
Icon: "/icon/terminal.svg",
|
||||
Seconds: usage.UsageReconnectingPtySeconds,
|
||||
|
@ -482,7 +482,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage
|
|||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "SSH",
|
||||
DisplayName: codersdk.TemplateBuiltinAppDisplayNameSSH,
|
||||
Slug: "ssh",
|
||||
Icon: "/icon/terminal.svg",
|
||||
Seconds: usage.UsageSshSeconds,
|
||||
|
|
|
@ -13,9 +13,13 @@ import (
|
|||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name"}, nil)
|
||||
var (
|
||||
templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name"}, nil)
|
||||
applicationsUsageSecondsDesc = prometheus.NewDesc("coderd_insights_applications_usage_seconds", "The application usage per template.", []string{"template_name", "application_name", "slug"}, nil)
|
||||
)
|
||||
|
||||
type MetricsCollector struct {
|
||||
database database.Store
|
||||
|
@ -28,6 +32,7 @@ type MetricsCollector struct {
|
|||
|
||||
type insightsData struct {
|
||||
templates []database.GetTemplateInsightsByTemplateRow
|
||||
apps []database.GetTemplateAppInsightsByTemplateRow
|
||||
|
||||
templateNames map[uuid.UUID]string
|
||||
}
|
||||
|
@ -70,9 +75,10 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||
// Phase 1: Fetch insights from database
|
||||
// FIXME errorGroup will be used to fetch insights for apps and parameters
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(1)
|
||||
eg.SetLimit(2)
|
||||
|
||||
var templateInsights []database.GetTemplateInsightsByTemplateRow
|
||||
var appInsights []database.GetTemplateAppInsightsByTemplateRow
|
||||
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
|
@ -85,13 +91,24 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||
}
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
appInsights, err = mc.database.GetTemplateAppInsightsByTemplate(egCtx, database.GetTemplateAppInsightsByTemplateParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
})
|
||||
if err != nil {
|
||||
mc.logger.Error(ctx, "unable to fetch application insights from database", slog.Error(err))
|
||||
}
|
||||
return err
|
||||
})
|
||||
err := eg.Wait()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Collect template IDs, and fetch relevant details
|
||||
templateIDs := uniqueTemplateIDs(templateInsights)
|
||||
templateIDs := uniqueTemplateIDs(templateInsights, appInsights)
|
||||
|
||||
templateNames := make(map[uuid.UUID]string, len(templateIDs))
|
||||
if len(templateIDs) > 0 {
|
||||
|
@ -107,7 +124,9 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||
|
||||
// Refresh the collector state
|
||||
mc.data.Store(&insightsData{
|
||||
templates: templateInsights,
|
||||
templates: templateInsights,
|
||||
apps: appInsights,
|
||||
|
||||
templateNames: templateNames,
|
||||
})
|
||||
}
|
||||
|
@ -133,6 +152,7 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||
|
||||
func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) {
|
||||
descCh <- templatesActiveUsersDesc
|
||||
descCh <- applicationsUsageSecondsDesc
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
|
||||
|
@ -143,6 +163,40 @@ func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
|
|||
return // insights data not loaded yet
|
||||
}
|
||||
|
||||
// Custom apps
|
||||
for _, appRow := range data.apps {
|
||||
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue, float64(appRow.UsageSeconds), data.templateNames[appRow.TemplateID],
|
||||
appRow.DisplayName.String, appRow.SlugOrPort)
|
||||
}
|
||||
|
||||
// Built-in apps
|
||||
for _, templateRow := range data.templates {
|
||||
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
|
||||
float64(templateRow.UsageVscodeSeconds),
|
||||
data.templateNames[templateRow.TemplateID],
|
||||
codersdk.TemplateBuiltinAppDisplayNameVSCode,
|
||||
"")
|
||||
|
||||
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
|
||||
float64(templateRow.UsageJetbrainsSeconds),
|
||||
data.templateNames[templateRow.TemplateID],
|
||||
codersdk.TemplateBuiltinAppDisplayNameJetBrains,
|
||||
"")
|
||||
|
||||
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
|
||||
float64(templateRow.UsageReconnectingPtySeconds),
|
||||
data.templateNames[templateRow.TemplateID],
|
||||
codersdk.TemplateBuiltinAppDisplayNameWebTerminal,
|
||||
"")
|
||||
|
||||
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
|
||||
float64(templateRow.UsageSshSeconds),
|
||||
data.templateNames[templateRow.TemplateID],
|
||||
codersdk.TemplateBuiltinAppDisplayNameSSH,
|
||||
"")
|
||||
}
|
||||
|
||||
// Templates
|
||||
for _, templateRow := range data.templates {
|
||||
metricsCh <- prometheus.MustNewConstMetric(templatesActiveUsersDesc, prometheus.GaugeValue, float64(templateRow.ActiveUsers), data.templateNames[templateRow.TemplateID])
|
||||
}
|
||||
|
@ -150,11 +204,14 @@ func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
|
|||
|
||||
// Helper functions below.
|
||||
|
||||
func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplateRow) []uuid.UUID {
|
||||
func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplateRow, appInsights []database.GetTemplateAppInsightsByTemplateRow) []uuid.UUID {
|
||||
tids := map[uuid.UUID]bool{}
|
||||
for _, t := range templateInsights {
|
||||
tids[t.TemplateID] = true
|
||||
}
|
||||
for _, t := range appInsights {
|
||||
tids[t.TemplateID] = true
|
||||
}
|
||||
|
||||
uniqueUUIDs := make([]uuid.UUID, len(tids))
|
||||
var i int
|
||||
|
|
|
@ -5,25 +5,30 @@ import (
|
|||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
io_prometheus_client "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/prometheusmetrics/insights"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestCollect_TemplateInsights(t *testing.T) {
|
||||
func TestCollectInsights(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
@ -53,9 +58,11 @@ func TestCollect_TemplateInsights(t *testing.T) {
|
|||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.PlanComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
ProvisionApply: provisionApplyWithAgentAndApp(authToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.Name = "golden-template"
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
|
||||
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
@ -66,6 +73,24 @@ func TestCollect_TemplateInsights(t *testing.T) {
|
|||
_ = agenttest.New(t, client.URL, authToken)
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
// Fake app usage
|
||||
reporter := workspaceapps.NewStatsDBReporter(db, workspaceapps.DefaultStatsDBReporterBatchSize)
|
||||
//nolint:gocritic // This is a test.
|
||||
err = reporter.Report(dbauthz.AsSystemRestricted(context.Background()), []workspaceapps.StatsReport{
|
||||
{
|
||||
UserID: user.UserID,
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: resources[0].Agents[0].ID,
|
||||
AccessMethod: "terminal",
|
||||
SlugOrPort: "golden-slug",
|
||||
SessionID: uuid.New(),
|
||||
SessionStartedAt: time.Now().Add(-3 * time.Minute),
|
||||
SessionEndedAt: time.Now().Add(-time.Minute).Add(-time.Second),
|
||||
Requests: 1,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "want no error inserting app stats")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
|
@ -97,6 +122,11 @@ func TestCollect_TemplateInsights(t *testing.T) {
|
|||
err = sess.Start("cat")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = sess.Close()
|
||||
_ = sshConn.Close()
|
||||
}()
|
||||
|
||||
goldenFile, err := os.ReadFile("testdata/insights-metrics.json")
|
||||
require.NoError(t, err)
|
||||
golden := map[string]int{}
|
||||
|
@ -112,9 +142,13 @@ func TestCollect_TemplateInsights(t *testing.T) {
|
|||
// Then
|
||||
for _, metric := range metrics {
|
||||
switch metric.GetName() {
|
||||
case "coderd_insights_templates_active_users":
|
||||
case "coderd_insights_applications_usage_seconds", "coderd_insights_templates_active_users":
|
||||
for _, m := range metric.Metric {
|
||||
collected[metric.GetName()] = int(m.Gauge.GetValue())
|
||||
key := metric.GetName()
|
||||
if len(m.Label) > 0 {
|
||||
key = key + "[" + metricLabelAsString(m) + "]"
|
||||
}
|
||||
collected[key] = int(m.Gauge.GetValue())
|
||||
}
|
||||
default:
|
||||
require.FailNowf(t, "unexpected metric collected", "metric: %s", metric.GetName())
|
||||
|
@ -122,11 +156,41 @@ func TestCollect_TemplateInsights(t *testing.T) {
|
|||
}
|
||||
|
||||
return assert.ObjectsAreEqualValues(golden, collected)
|
||||
}, testutil.WaitMedium, testutil.IntervalFast, "template insights are missing")
|
||||
|
||||
// We got our latency metrics, close the connection.
|
||||
_ = sess.Close()
|
||||
_ = sshConn.Close()
|
||||
|
||||
require.EqualValues(t, golden, collected)
|
||||
}, testutil.WaitMedium, testutil.IntervalFast, "template insights are inconsistent with golden files, got: %v", collected)
|
||||
}
|
||||
|
||||
func metricLabelAsString(m *io_prometheus_client.Metric) string {
|
||||
var labels []string
|
||||
for _, labelPair := range m.Label {
|
||||
labels = append(labels, labelPair.GetName()+"="+labelPair.GetValue())
|
||||
}
|
||||
return strings.Join(labels, ",")
|
||||
}
|
||||
|
||||
func provisionApplyWithAgentAndApp(authToken string) []*proto.Response {
|
||||
return []*proto.Response{{
|
||||
Type: &proto.Response_Apply{
|
||||
Apply: &proto.ApplyComplete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "example",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
Apps: []*proto.App{
|
||||
{
|
||||
Slug: "golden-slug",
|
||||
DisplayName: "Golden Slug",
|
||||
SharingLevel: proto.AppSharingLevel_OWNER,
|
||||
Url: "http://localhost:1234",
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
{
|
||||
"coderd_insights_templates_active_users": 1
|
||||
"coderd_insights_applications_usage_seconds[application_name=JetBrains,slug=,template_name=golden-template]": 0,
|
||||
"coderd_insights_applications_usage_seconds[application_name=Visual Studio Code,slug=,template_name=golden-template]": 0,
|
||||
"coderd_insights_applications_usage_seconds[application_name=Web Terminal,slug=,template_name=golden-template]": 0,
|
||||
"coderd_insights_applications_usage_seconds[application_name=SSH,slug=,template_name=golden-template]": 60,
|
||||
"coderd_insights_applications_usage_seconds[application_name=Golden Slug,slug=golden-slug,template_name=golden-template]": 180,
|
||||
"coderd_insights_templates_active_users[template_name=golden-template]": 1
|
||||
}
|
||||
|
|
|
@ -200,6 +200,14 @@ const (
|
|||
TemplateAppsTypeApp TemplateAppsType = "app"
|
||||
)
|
||||
|
||||
// Enums define the display name of the builtin app reported.
|
||||
const (
|
||||
TemplateBuiltinAppDisplayNameVSCode string = "Visual Studio Code"
|
||||
TemplateBuiltinAppDisplayNameJetBrains string = "JetBrains"
|
||||
TemplateBuiltinAppDisplayNameWebTerminal string = "Web Terminal"
|
||||
TemplateBuiltinAppDisplayNameSSH string = "SSH"
|
||||
)
|
||||
|
||||
// TemplateAppUsage shows the usage of an app for one or more templates.
|
||||
type TemplateAppUsage struct {
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
|
|
Loading…
Reference in New Issue