mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
feat: expose parameter insights as Prometheus metrics (#10574)
This commit is contained in:
@ -7,18 +7,21 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/util/slice"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name"}, nil)
|
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)
|
applicationsUsageSecondsDesc = prometheus.NewDesc("coderd_insights_applications_usage_seconds", "The application usage per template.", []string{"template_name", "application_name", "slug"}, nil)
|
||||||
|
parametersDesc = prometheus.NewDesc("coderd_insights_parameters", "The parameter usage per template.", []string{"template_name", "parameter_name", "parameter_type", "parameter_value"}, nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
type MetricsCollector struct {
|
type MetricsCollector struct {
|
||||||
@ -33,10 +36,20 @@ type MetricsCollector struct {
|
|||||||
type insightsData struct {
|
type insightsData struct {
|
||||||
templates []database.GetTemplateInsightsByTemplateRow
|
templates []database.GetTemplateInsightsByTemplateRow
|
||||||
apps []database.GetTemplateAppInsightsByTemplateRow
|
apps []database.GetTemplateAppInsightsByTemplateRow
|
||||||
|
params []parameterRow
|
||||||
|
|
||||||
templateNames map[uuid.UUID]string
|
templateNames map[uuid.UUID]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type parameterRow struct {
|
||||||
|
templateID uuid.UUID
|
||||||
|
name string
|
||||||
|
aType string
|
||||||
|
value string
|
||||||
|
|
||||||
|
count int64
|
||||||
|
}
|
||||||
|
|
||||||
var _ prometheus.Collector = new(MetricsCollector)
|
var _ prometheus.Collector = new(MetricsCollector)
|
||||||
|
|
||||||
func NewMetricsCollector(db database.Store, logger slog.Logger, timeWindow time.Duration, tickInterval time.Duration) (*MetricsCollector, error) {
|
func NewMetricsCollector(db database.Store, logger slog.Logger, timeWindow time.Duration, tickInterval time.Duration) (*MetricsCollector, error) {
|
||||||
@ -75,10 +88,11 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||||||
// Phase 1: Fetch insights from database
|
// Phase 1: Fetch insights from database
|
||||||
// FIXME errorGroup will be used to fetch insights for apps and parameters
|
// FIXME errorGroup will be used to fetch insights for apps and parameters
|
||||||
eg, egCtx := errgroup.WithContext(ctx)
|
eg, egCtx := errgroup.WithContext(ctx)
|
||||||
eg.SetLimit(2)
|
eg.SetLimit(3)
|
||||||
|
|
||||||
var templateInsights []database.GetTemplateInsightsByTemplateRow
|
var templateInsights []database.GetTemplateInsightsByTemplateRow
|
||||||
var appInsights []database.GetTemplateAppInsightsByTemplateRow
|
var appInsights []database.GetTemplateAppInsightsByTemplateRow
|
||||||
|
var paramInsights []parameterRow
|
||||||
|
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var err error
|
var err error
|
||||||
@ -102,13 +116,25 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
rows, err := mc.database.GetTemplateParameterInsights(egCtx, database.GetTemplateParameterInsightsParams{
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: endTime,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
mc.logger.Error(ctx, "unable to fetch parameter insights from database", slog.Error(err))
|
||||||
|
}
|
||||||
|
paramInsights = convertParameterInsights(rows)
|
||||||
|
return err
|
||||||
|
})
|
||||||
err := eg.Wait()
|
err := eg.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Collect template IDs, and fetch relevant details
|
// Phase 2: Collect template IDs, and fetch relevant details
|
||||||
templateIDs := uniqueTemplateIDs(templateInsights, appInsights)
|
templateIDs := uniqueTemplateIDs(templateInsights, appInsights, paramInsights)
|
||||||
|
|
||||||
templateNames := make(map[uuid.UUID]string, len(templateIDs))
|
templateNames := make(map[uuid.UUID]string, len(templateIDs))
|
||||||
if len(templateIDs) > 0 {
|
if len(templateIDs) > 0 {
|
||||||
@ -126,6 +152,7 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||||||
mc.data.Store(&insightsData{
|
mc.data.Store(&insightsData{
|
||||||
templates: templateInsights,
|
templates: templateInsights,
|
||||||
apps: appInsights,
|
apps: appInsights,
|
||||||
|
params: paramInsights,
|
||||||
|
|
||||||
templateNames: templateNames,
|
templateNames: templateNames,
|
||||||
})
|
})
|
||||||
@ -153,6 +180,7 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
|
|||||||
func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) {
|
func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) {
|
||||||
descCh <- templatesActiveUsersDesc
|
descCh <- templatesActiveUsersDesc
|
||||||
descCh <- applicationsUsageSecondsDesc
|
descCh <- applicationsUsageSecondsDesc
|
||||||
|
descCh <- parametersDesc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
|
func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
|
||||||
@ -200,11 +228,16 @@ func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
|
|||||||
for _, templateRow := range data.templates {
|
for _, templateRow := range data.templates {
|
||||||
metricsCh <- prometheus.MustNewConstMetric(templatesActiveUsersDesc, prometheus.GaugeValue, float64(templateRow.ActiveUsers), data.templateNames[templateRow.TemplateID])
|
metricsCh <- prometheus.MustNewConstMetric(templatesActiveUsersDesc, prometheus.GaugeValue, float64(templateRow.ActiveUsers), data.templateNames[templateRow.TemplateID])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parameters
|
||||||
|
for _, parameterRow := range data.params {
|
||||||
|
metricsCh <- prometheus.MustNewConstMetric(parametersDesc, prometheus.GaugeValue, float64(parameterRow.count), data.templateNames[parameterRow.templateID], parameterRow.name, parameterRow.aType, parameterRow.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions below.
|
// Helper functions below.
|
||||||
|
|
||||||
func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplateRow, appInsights []database.GetTemplateAppInsightsByTemplateRow) []uuid.UUID {
|
func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplateRow, appInsights []database.GetTemplateAppInsightsByTemplateRow, paramInsights []parameterRow) []uuid.UUID {
|
||||||
tids := map[uuid.UUID]bool{}
|
tids := map[uuid.UUID]bool{}
|
||||||
for _, t := range templateInsights {
|
for _, t := range templateInsights {
|
||||||
tids[t.TemplateID] = true
|
tids[t.TemplateID] = true
|
||||||
@ -212,6 +245,9 @@ func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplate
|
|||||||
for _, t := range appInsights {
|
for _, t := range appInsights {
|
||||||
tids[t.TemplateID] = true
|
tids[t.TemplateID] = true
|
||||||
}
|
}
|
||||||
|
for _, t := range paramInsights {
|
||||||
|
tids[t.templateID] = true
|
||||||
|
}
|
||||||
|
|
||||||
uniqueUUIDs := make([]uuid.UUID, len(tids))
|
uniqueUUIDs := make([]uuid.UUID, len(tids))
|
||||||
var i int
|
var i int
|
||||||
@ -229,3 +265,54 @@ func onlyTemplateNames(templates []database.Template) map[uuid.UUID]string {
|
|||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertParameterInsights(rows []database.GetTemplateParameterInsightsRow) []parameterRow {
|
||||||
|
type uniqueKey struct {
|
||||||
|
templateID uuid.UUID
|
||||||
|
parameterName string
|
||||||
|
parameterType string
|
||||||
|
parameterValue string
|
||||||
|
}
|
||||||
|
|
||||||
|
m := map[uniqueKey]int64{}
|
||||||
|
for _, r := range rows {
|
||||||
|
for _, t := range r.TemplateIDs {
|
||||||
|
key := uniqueKey{
|
||||||
|
templateID: t,
|
||||||
|
parameterName: r.Name,
|
||||||
|
parameterType: r.Type,
|
||||||
|
parameterValue: r.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := m[key]; !ok {
|
||||||
|
m[key] = 0
|
||||||
|
}
|
||||||
|
m[key] = m[key] + r.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
converted := make([]parameterRow, len(m))
|
||||||
|
var i int
|
||||||
|
for k, c := range m {
|
||||||
|
converted[i] = parameterRow{
|
||||||
|
templateID: k.templateID,
|
||||||
|
name: k.parameterName,
|
||||||
|
aType: k.parameterType,
|
||||||
|
value: k.parameterValue,
|
||||||
|
count: c,
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(converted, func(a, b parameterRow) int {
|
||||||
|
if a.templateID != b.templateID {
|
||||||
|
return slice.Ascending(a.templateID.String(), b.templateID.String())
|
||||||
|
}
|
||||||
|
if a.name != b.name {
|
||||||
|
return slice.Ascending(a.name, b.name)
|
||||||
|
}
|
||||||
|
return slice.Ascending(a.value, b.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
@ -57,7 +57,7 @@ func TestCollectInsights(t *testing.T) {
|
|||||||
authToken := uuid.NewString()
|
authToken := uuid.NewString()
|
||||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||||
Parse: echo.ParseComplete,
|
Parse: echo.ParseComplete,
|
||||||
ProvisionPlan: echo.PlanComplete,
|
ProvisionPlan: provisionPlanWithParameters(),
|
||||||
ProvisionApply: provisionApplyWithAgentAndApp(authToken),
|
ProvisionApply: provisionApplyWithAgentAndApp(authToken),
|
||||||
})
|
})
|
||||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||||
@ -66,7 +66,13 @@ func TestCollectInsights(t *testing.T) {
|
|||||||
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
|
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
|
||||||
|
|
||||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||||
|
cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||||
|
{Name: "first_parameter", Value: "Foobar"},
|
||||||
|
{Name: "second_parameter", Value: "true"},
|
||||||
|
{Name: "third_parameter", Value: "789"},
|
||||||
|
}
|
||||||
|
})
|
||||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||||
|
|
||||||
// Start an agent so that we can generate stats.
|
// Start an agent so that we can generate stats.
|
||||||
@ -142,7 +148,7 @@ func TestCollectInsights(t *testing.T) {
|
|||||||
// Then
|
// Then
|
||||||
for _, metric := range metrics {
|
for _, metric := range metrics {
|
||||||
switch metric.GetName() {
|
switch metric.GetName() {
|
||||||
case "coderd_insights_applications_usage_seconds", "coderd_insights_templates_active_users":
|
case "coderd_insights_applications_usage_seconds", "coderd_insights_templates_active_users", "coderd_insights_parameters":
|
||||||
for _, m := range metric.Metric {
|
for _, m := range metric.Metric {
|
||||||
key := metric.GetName()
|
key := metric.GetName()
|
||||||
if len(m.Label) > 0 {
|
if len(m.Label) > 0 {
|
||||||
@ -167,6 +173,22 @@ func metricLabelAsString(m *io_prometheus_client.Metric) string {
|
|||||||
return strings.Join(labels, ",")
|
return strings.Join(labels, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func provisionPlanWithParameters() []*proto.Response {
|
||||||
|
return []*proto.Response{
|
||||||
|
{
|
||||||
|
Type: &proto.Response_Plan{
|
||||||
|
Plan: &proto.PlanComplete{
|
||||||
|
Parameters: []*proto.RichParameter{
|
||||||
|
{Name: "first_parameter", Type: "string", Mutable: true},
|
||||||
|
{Name: "second_parameter", Type: "bool", Mutable: true},
|
||||||
|
{Name: "third_parameter", Type: "number", Mutable: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func provisionApplyWithAgentAndApp(authToken string) []*proto.Response {
|
func provisionApplyWithAgentAndApp(authToken string) []*proto.Response {
|
||||||
return []*proto.Response{{
|
return []*proto.Response{{
|
||||||
Type: &proto.Response_Apply{
|
Type: &proto.Response_Apply{
|
||||||
|
@ -4,5 +4,8 @@
|
|||||||
"coderd_insights_applications_usage_seconds[application_name=Web Terminal,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=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_applications_usage_seconds[application_name=Golden Slug,slug=golden-slug,template_name=golden-template]": 180,
|
||||||
|
"coderd_insights_parameters[parameter_name=first_parameter,parameter_type=string,parameter_value=Foobar,template_name=golden-template]": 1,
|
||||||
|
"coderd_insights_parameters[parameter_name=second_parameter,parameter_type=bool,parameter_value=true,template_name=golden-template]": 1,
|
||||||
|
"coderd_insights_parameters[parameter_name=third_parameter,parameter_type=number,parameter_value=789,template_name=golden-template]": 1,
|
||||||
"coderd_insights_templates_active_users[template_name=golden-template]": 1
|
"coderd_insights_templates_active_users[template_name=golden-template]": 1
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user