diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 7d0017a0af..b49890589a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -375,6 +375,11 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { api.AGPL.WorkspaceProxyHostsFn.Store(&f) } + err = api.PrometheusRegistry.Register(&api.licenseMetricsCollector) + if err != nil { + return nil, xerrors.Errorf("unable to register license metrics collector") + } + err = api.updateEntitlements(ctx) if err != nil { return nil, xerrors.Errorf("update entitlements: %w", err) @@ -434,6 +439,8 @@ type API struct { entitlements codersdk.Entitlements provisionerDaemonAuth *provisionerDaemonAuth + + licenseMetricsCollector license.MetricsCollector } func (api *API) Close() error { @@ -660,8 +667,8 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.entitlementsMu.Lock() defer api.entitlementsMu.Unlock() api.entitlements = entitlements + api.licenseMetricsCollector.Entitlements.Store(&entitlements) api.AGPL.SiteHandler.Entitlements.Store(&entitlements) - return nil } diff --git a/enterprise/coderd/license/metricscollector.go b/enterprise/coderd/license/metricscollector.go new file mode 100644 index 0000000000..85aac23b2f --- /dev/null +++ b/enterprise/coderd/license/metricscollector.go @@ -0,0 +1,53 @@ +package license + +import ( + "sync/atomic" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/coder/coder/v2/codersdk" +) + +var ( + activeUsersDesc = prometheus.NewDesc("coderd_license_active_users", "The number of active users.", nil, nil) + limitUsersDesc = prometheus.NewDesc("coderd_license_limit_users", "The user seats limit based on the active Coder license.", nil, nil) + userLimitEnabledDesc = prometheus.NewDesc("coderd_license_user_limit_enabled", "Returns 1 if the current license enforces the user limit.", nil, nil) +) + +type MetricsCollector struct { + Entitlements atomic.Pointer[codersdk.Entitlements] +} + +var _ prometheus.Collector = new(MetricsCollector) + +func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) { + descCh <- activeUsersDesc + descCh <- limitUsersDesc + descCh <- userLimitEnabledDesc +} + +func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) { + entitlements := mc.Entitlements.Load() + if entitlements == nil || entitlements.Features == nil { + return + } + + userLimitEntitlement, ok := entitlements.Features[codersdk.FeatureUserLimit] + if !ok { + return + } + + var enabled float64 + if userLimitEntitlement.Enabled { + enabled = 1 + } + metricsCh <- prometheus.MustNewConstMetric(userLimitEnabledDesc, prometheus.GaugeValue, enabled) + + if userLimitEntitlement.Actual != nil { + metricsCh <- prometheus.MustNewConstMetric(activeUsersDesc, prometheus.GaugeValue, float64(*userLimitEntitlement.Actual)) + } + + if userLimitEntitlement.Limit != nil { + metricsCh <- prometheus.MustNewConstMetric(limitUsersDesc, prometheus.GaugeValue, float64(*userLimitEntitlement.Limit)) + } +} diff --git a/enterprise/coderd/license/metricscollector_test.go b/enterprise/coderd/license/metricscollector_test.go new file mode 100644 index 0000000000..36661c8cdb --- /dev/null +++ b/enterprise/coderd/license/metricscollector_test.go @@ -0,0 +1,63 @@ +package license_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/aws/smithy-go/ptr" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/license" +) + +func TestCollectLicenseMetrics(t *testing.T) { + t.Parallel() + + // Given + registry := prometheus.NewRegistry() + + var sut license.MetricsCollector + + const ( + actualUsers = 4 + userLimit = 7 + ) + sut.Entitlements.Store(&codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{ + codersdk.FeatureUserLimit: { + Enabled: true, + Actual: ptr.Int64(actualUsers), + Limit: ptr.Int64(userLimit), + }, + }, + }) + + registry.Register(&sut) + + // When + metrics, err := registry.Gather() + require.NoError(t, err) + + // Then + goldenFile, err := os.ReadFile("testdata/license-metrics.json") + require.NoError(t, err) + golden := map[string]int{} + err = json.Unmarshal(goldenFile, &golden) + require.NoError(t, err) + + collected := map[string]int{} + for _, metric := range metrics { + switch metric.GetName() { + case "coderd_license_active_users", "coderd_license_limit_users", "coderd_license_user_limit_enabled": + for _, m := range metric.Metric { + collected[metric.GetName()] = int(m.Gauge.GetValue()) + } + default: + require.FailNowf(t, "unexpected metric collected", "metric: %s", metric.GetName()) + } + } + require.EqualValues(t, golden, collected) +} diff --git a/enterprise/coderd/license/testdata/license-metrics.json b/enterprise/coderd/license/testdata/license-metrics.json new file mode 100644 index 0000000000..7326b47da7 --- /dev/null +++ b/enterprise/coderd/license/testdata/license-metrics.json @@ -0,0 +1,5 @@ +{ + "coderd_license_active_users": 4, + "coderd_license_limit_users": 7, + "coderd_license_user_limit_enabled": 1 +}