diff --git a/pkg/infra/usagestats/service/service.go b/pkg/infra/usagestats/service/service.go index ceb8a321fbc..486f986f697 100644 --- a/pkg/infra/usagestats/service/service.go +++ b/pkg/infra/usagestats/service/service.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) @@ -22,6 +23,7 @@ type UsageStats struct { SocialService social.Service kvStore *kvstore.NamespacedKVStore RouteRegister routing.RouteRegister + features *featuremgmt.FeatureManager log log.Logger @@ -33,11 +35,12 @@ type UsageStats struct { } func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugins.Store, - socialService social.Service, kvStore kvstore.KVStore, routeRegister routing.RouteRegister, + socialService social.Service, kvStore kvstore.KVStore, routeRegister routing.RouteRegister, features *featuremgmt.FeatureManager, ) *UsageStats { s := &UsageStats{ Cfg: cfg, SQLStore: sqlStore, + features: features, oauthProviders: socialService.GetOAuthProviders(), RouteRegister: routeRegister, pluginStore: pluginStore, diff --git a/pkg/infra/usagestats/service/usage_stats.go b/pkg/infra/usagestats/service/usage_stats.go index b67e1db0aa9..a9e7b4fdf6b 100644 --- a/pkg/infra/usagestats/service/usage_stats.go +++ b/pkg/infra/usagestats/service/usage_stats.go @@ -230,6 +230,11 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e metrics["stats.uptime"] = int64(time.Since(uss.startTime).Seconds()) + featureUsageStats := uss.features.GetUsageStats(ctx) + for name, val := range featureUsageStats { + metrics[name] = val + } + return report, nil } diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index 13acd1155aa..a150e26d316 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" @@ -405,6 +406,14 @@ func TestMetrics(t *testing.T) { metric := report.Metrics[metricName] assert.Equal(t, 1, metric) }) + + t.Run("Should include feature usage stats", func(t *testing.T) { + report, err := uss.GetUsageReport(context.Background()) + require.NoError(t, err, "Expected no error") + + assert.Equal(t, 1, report.Metrics["stats.features.feature_1.count"]) + assert.Equal(t, 1, report.Metrics["stats.features.feature_2.count"]) + }) }) t.Run("When registering external metrics", func(t *testing.T) { @@ -511,5 +520,6 @@ func createService(t *testing.T, cfg setting.Cfg, sqlStore sqlstore.Store, withD log: log.New("infra.usagestats"), startTime: time.Now().Add(-1 * time.Minute), RouteRegister: routing.NewRouteRegister(), + features: featuremgmt.WithFeatures("feature1", "feature2"), } } diff --git a/pkg/services/featuremgmt/strcase/snake.go b/pkg/services/featuremgmt/strcase/snake.go new file mode 100644 index 00000000000..012fdb181d2 --- /dev/null +++ b/pkg/services/featuremgmt/strcase/snake.go @@ -0,0 +1,118 @@ +// https://github.com/iancoleman/strcase/blob/master/snake.go + +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 Ian Coleman + * Copyright (c) 2018 Ma_124, + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, Subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or Substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package strcase + +import ( + "strings" +) + +// ToSnake converts a string to snake_case +func ToSnake(s string) string { + return ToDelimited(s, '_') +} + +func ToSnakeWithIgnore(s string, ignore string) string { + return ToScreamingDelimited(s, '_', ignore, false) +} + +// ToScreamingSnake converts a string to SCREAMING_SNAKE_CASE +func ToScreamingSnake(s string) string { + return ToScreamingDelimited(s, '_', "", true) +} + +// ToKebab converts a string to kebab-case +func ToKebab(s string) string { + return ToDelimited(s, '-') +} + +// ToScreamingKebab converts a string to SCREAMING-KEBAB-CASE +func ToScreamingKebab(s string) string { + return ToScreamingDelimited(s, '-', "", true) +} + +// ToDelimited converts a string to delimited.snake.case +// (in this case `delimiter = '.'`) +func ToDelimited(s string, delimiter uint8) string { + return ToScreamingDelimited(s, delimiter, "", false) +} + +// ToScreamingDelimited converts a string to SCREAMING.DELIMITED.SNAKE.CASE +// (in this case `delimiter = '.'; screaming = true`) +// or delimited.snake.case +// (in this case `delimiter = '.'; screaming = false`) +//nolint: gocyclo +func ToScreamingDelimited(s string, delimiter uint8, ignore string, screaming bool) string { + s = strings.TrimSpace(s) + n := strings.Builder{} + n.Grow(len(s) + 2) // nominal 2 bytes of extra space for inserted delimiters + for i, v := range []byte(s) { + vIsCap := v >= 'A' && v <= 'Z' + vIsLow := v >= 'a' && v <= 'z' + if vIsLow && screaming { + v += 'A' + v -= 'a' + } else if vIsCap && !screaming { + v += 'a' + v -= 'A' + } + + // treat acronyms as words, eg for JSONData -> JSON is a whole word + if i+1 < len(s) { + next := s[i+1] + vIsNum := v >= '0' && v <= '9' + nextIsCap := next >= 'A' && next <= 'Z' + nextIsLow := next >= 'a' && next <= 'z' + nextIsNum := next >= '0' && next <= '9' + // add underscore if next letter case type is changed + if (vIsCap && (nextIsLow || nextIsNum)) || (vIsLow && (nextIsCap || nextIsNum)) || (vIsNum && (nextIsCap || nextIsLow)) { + prevIgnore := ignore != "" && i > 0 && strings.ContainsAny(string(s[i-1]), ignore) + if !prevIgnore { + if vIsCap && nextIsLow { + if prevIsCap := i > 0 && s[i-1] >= 'A' && s[i-1] <= 'Z'; prevIsCap { + n.WriteByte(delimiter) + } + } + n.WriteByte(v) + if vIsLow || vIsNum || nextIsNum { + n.WriteByte(delimiter) + } + continue + } + } + } + + if (v == ' ' || v == '_' || v == '-' || v == '.') && !strings.ContainsAny(string(v), ignore) { + // replace space/underscore/hyphen/dot with delimiter + n.WriteByte(delimiter) + } else { + n.WriteByte(v) + } + } + + return n.String() +} diff --git a/pkg/services/featuremgmt/usage_stats.go b/pkg/services/featuremgmt/usage_stats.go new file mode 100644 index 00000000000..f33e77834a6 --- /dev/null +++ b/pkg/services/featuremgmt/usage_stats.go @@ -0,0 +1,21 @@ +package featuremgmt + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/pkg/services/featuremgmt/strcase" +) + +func (fm *FeatureManager) GetUsageStats(ctx context.Context) map[string]interface{} { + enabled := fm.GetEnabled(ctx) + stats := make(map[string]interface{}, len(enabled)) + for featureName := range enabled { + stats[asMetricName(featureName)] = 1 + } + return stats +} + +func asMetricName(featureName string) string { + return fmt.Sprintf("stats.features.%s.count", strcase.ToSnake(featureName)) +} diff --git a/pkg/services/featuremgmt/usage_stats_test.go b/pkg/services/featuremgmt/usage_stats_test.go new file mode 100644 index 00000000000..1bc5420e4ff --- /dev/null +++ b/pkg/services/featuremgmt/usage_stats_test.go @@ -0,0 +1,34 @@ +package featuremgmt + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFeatureUsageStats(t *testing.T) { + featureManagerWithAllFeatures := WithFeatures( + "trimDefaults", + "httpclientprovider_azure_auth", + "service-accounts", + "database_metrics", + "dashboardPreviews", + "live-config", + "showFeatureFlagsInUI", + "UPPER_SNAKE_CASE", + "feature.with.a.dot", + ) + + require.Equal(t, map[string]interface{}{ + "stats.features.trim_defaults.count": 1, + "stats.features.httpclientprovider_azure_auth.count": 1, + "stats.features.service_accounts.count": 1, + "stats.features.database_metrics.count": 1, + "stats.features.dashboard_previews.count": 1, + "stats.features.live_config.count": 1, + "stats.features.show_feature_flags_in_ui.count": 1, + "stats.features.upper_snake_case.count": 1, + "stats.features.feature_with_a_dot.count": 1, + }, featureManagerWithAllFeatures.GetUsageStats(context.Background())) +}