diff --git a/internal/mode/static/telemetry/collector.go b/internal/mode/static/telemetry/collector.go index 58fb7f74da..0be797d0ad 100644 --- a/internal/mode/static/telemetry/collector.go +++ b/internal/mode/static/telemetry/collector.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "runtime" + "sort" + "strings" tel "github.com/nginxinc/telemetry-exporter/pkg/telemetry" appsv1 "k8s.io/api/apps/v1" @@ -14,6 +16,7 @@ import ( k8sversion "k8s.io/apimachinery/pkg/util/version" "sigs.k8s.io/controller-runtime/pkg/client" + ngfAPI "github.com/nginxinc/nginx-gateway-fabric/apis/v1alpha1" "github.com/nginxinc/nginx-gateway-fabric/internal/framework/kinds" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/config" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/dataplane" @@ -46,8 +49,15 @@ type Data struct { // FlagValues contains the values of the command-line flags, where each value corresponds to the flag from FlagNames // at the same index. // Each value is either 'true' or 'false' for boolean flags and 'default' or 'user-defined' for non-boolean flags. - FlagValues []string - NGFResourceCounts // embedding is required by the generator. + FlagValues []string + // SnippetsFiltersContextDirectives contains the context-directive strings of all applied SnippetsFilters. + // Both lists are ordered first by count, then by lexicographical order on the context-directive string. + SnippetsFiltersContextDirectives []string + // SnippetsFiltersContextDirectivesCount contains the count of the context-directive strings, where each count + // corresponds to the string from SnippetsFiltersContextDirectives at the same index. Both lists are ordered + // first by count, then by lexicographical order on the context-directive string. + SnippetsFiltersContextDirectivesCount []int64 + NGFResourceCounts // embedding is required by the generator. // NGFReplicaCount is the number of replicas of the NGF Pod. NGFReplicaCount int64 } @@ -146,6 +156,13 @@ func (c DataCollectorImpl) Collect(ctx context.Context) (Data, error) { return Data{}, fmt.Errorf("failed to get NGF deploymentID: %w", err) } + snippetsFiltersContextDirectives, + snippetsFiltersContextDirectivesCount, + err := collectSnippetsFilterSnippetsInfo(c.cfg.GraphGetter) + if err != nil { + return Data{}, fmt.Errorf("failed to collect snippet filter directive info: %w", err) + } + data := Data{ Data: tel.Data{ ProjectName: "NGF", @@ -162,6 +179,9 @@ func (c DataCollectorImpl) Collect(ctx context.Context) (Data, error) { FlagNames: c.cfg.Flags.Names, FlagValues: c.cfg.Flags.Values, NGFReplicaCount: int64(replicaCount), + // maybe SnippetValues? + SnippetsFiltersContextDirectives: snippetsFiltersContextDirectives, + SnippetsFiltersContextDirectivesCount: snippetsFiltersContextDirectivesCount, } return data, nil @@ -382,3 +402,110 @@ func collectClusterInformation(ctx context.Context, k8sClient client.Reader) (cl return clusterInfo, nil } + +type sfContextDirective struct { + context string + directive string +} + +func collectSnippetsFilterSnippetsInfo(graphGetter GraphGetter) ([]string, []int64, error) { + g := graphGetter.GetLatestGraph() + if g == nil { + return nil, nil, errors.New("latest graph cannot be nil") + } + + contextDirectiveMap := make(map[sfContextDirective]int) + + for name := range g.SnippetsFilters { + sf := g.SnippetsFilters[name] + if sf == nil { + continue + } + + for nginxContext := range sf.Snippets { + snippetValue := sf.Snippets[nginxContext] + + var parsedContext string + switch nginxContext { + case ngfAPI.NginxContextMain: + parsedContext = "main" + case ngfAPI.NginxContextHTTP: + parsedContext = "http" + case ngfAPI.NginxContextHTTPServer: + parsedContext = "server" + case ngfAPI.NginxContextHTTPServerLocation: + parsedContext = "location" + default: + parsedContext = "unknown" + } + + directives := parseSnippetValueIntoDirectives(snippetValue) + + for _, directive := range directives { + contextDirective := sfContextDirective{ + context: parsedContext, + directive: directive, + } + + contextDirectiveMap[contextDirective]++ + } + } + } + + contextDirectiveList, countList := parseContextDirectiveMapIntoLists(contextDirectiveMap) + + return contextDirectiveList, countList, nil +} + +func parseSnippetValueIntoDirectives(snippetValue string) []string { + separatedDirectives := strings.Split(snippetValue, ";") + directives := make([]string, 0, len(separatedDirectives)) + + for _, directive := range separatedDirectives { + // the strings.TrimSpace is needed in the case of multi-line NGINX Snippet values + directive = strings.Split(strings.TrimSpace(directive), " ")[0] + + // splitting on the delimiting character can result in a directive being empty or a space/newline character, + // so we check here to ensure it's not + if directive != "" { + directives = append(directives, directive) + } + } + + return directives +} + +// parseContextDirectiveMapIntoLists returns two same-length lists where the elements at each corresponding index +// are paired together. +// The first list contains strings which are the NGINX context and directive of a Snippet joined with a hyphen. +// The second list contains ints which are the count of total same context-directive values of the first list. +// Both lists are ordered based off of count first, then lexicographically on the context-directive string. +func parseContextDirectiveMapIntoLists(contextDirectiveMap map[sfContextDirective]int) ([]string, []int64) { + type sfContextDirectiveCount struct { + contextDirective string + count int64 + } + + kvPairs := make([]sfContextDirectiveCount, 0, len(contextDirectiveMap)) + + for k, v := range contextDirectiveMap { + kvPairs = append(kvPairs, sfContextDirectiveCount{k.context + "-" + k.directive, int64(v)}) + } + + sort.Slice(kvPairs, func(i, j int) bool { + if kvPairs[i].count == kvPairs[j].count { + return kvPairs[i].contextDirective < kvPairs[j].contextDirective + } + return kvPairs[i].count > kvPairs[j].count + }) + + contextDirectiveList := make([]string, len(kvPairs)) + countList := make([]int64, len(kvPairs)) + + for i, pair := range kvPairs { + contextDirectiveList[i] = pair.contextDirective + countList[i] = pair.count + } + + return contextDirectiveList, countList +} diff --git a/internal/mode/static/telemetry/collector_test.go b/internal/mode/static/telemetry/collector_test.go index d688f1f58e..ab9ec915d2 100644 --- a/internal/mode/static/telemetry/collector_test.go +++ b/internal/mode/static/telemetry/collector_test.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + ngfAPI "github.com/nginxinc/nginx-gateway-fabric/apis/v1alpha1" "github.com/nginxinc/nginx-gateway-fabric/internal/framework/events/eventsfakes" "github.com/nginxinc/nginx-gateway-fabric/internal/framework/kinds" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/config" @@ -168,11 +169,13 @@ var _ = Describe("Collector", Ordered, func() { InstallationID: string(ngfReplicaSet.ObjectMeta.OwnerReferences[0].UID), ClusterNodeCount: 1, }, - NGFResourceCounts: telemetry.NGFResourceCounts{}, - NGFReplicaCount: 1, - ImageSource: "local", - FlagNames: flags.Names, - FlagValues: flags.Values, + NGFResourceCounts: telemetry.NGFResourceCounts{}, + NGFReplicaCount: 1, + ImageSource: "local", + FlagNames: flags.Names, + FlagValues: flags.Values, + SnippetsFiltersContextDirectives: []string{}, + SnippetsFiltersContextDirectivesCount: []int64{}, } k8sClientReader = &eventsfakes.FakeReader{} @@ -329,8 +332,26 @@ var _ = Describe("Collector", Ordered, func() { }, NginxProxy: &graph.NginxProxy{}, SnippetsFilters: map[types.NamespacedName]*graph.SnippetsFilter{ - {Namespace: "test", Name: "sf-1"}: {}, - {Namespace: "test", Name: "sf-2"}: {}, + {Namespace: "test", Name: "sf-1"}: { + Snippets: map[ngfAPI.NginxContext]string{ + ngfAPI.NginxContextMain: "worker_priority 0;", + ngfAPI.NginxContextHTTP: "aio on;", + ngfAPI.NginxContextHTTPServer: "auth_delay 10s;", + ngfAPI.NginxContextHTTPServerLocation: "keepalive_time 10s;", + }, + }, + {Namespace: "test", Name: "sf-2"}: { + Snippets: map[ngfAPI.NginxContext]string{ + // String representation of multi-line yaml value using > character + ngfAPI.NginxContextMain: "worker_priority 1; worker_rlimit_nofile 50;\n", + // String representation of NGINX values on same line + ngfAPI.NginxContextHTTP: "aio off; client_body_timeout 70s;", + // String representation of multi-line yaml using no special character besides a new line + ngfAPI.NginxContextHTTPServer: "auth_delay 100s; ignore_invalid_headers off;", + // String representation of multi-line yaml value using | character + ngfAPI.NginxContextHTTPServerLocation: "keepalive_time 100s;\nallow 10.0.0.0/8;\n", + }, + }, {Namespace: "test", Name: "sf-3"}: {}, }, } @@ -389,10 +410,38 @@ var _ = Describe("Collector", Ordered, func() { expData.ClusterVersion = "1.29.2" expData.ClusterPlatform = "kind" - data, err := dataCollector.Collect(ctx) + expData.SnippetsFiltersContextDirectives = []string{ + "http-aio", + "location-keepalive_time", + "main-worker_priority", + "server-auth_delay", + "http-client_body_timeout", + "location-allow", + "main-worker_rlimit_nofile", + "server-ignore_invalid_headers", + } + expData.SnippetsFiltersContextDirectivesCount = []int64{ + 2, + 2, + 2, + 2, + 1, + 1, + 1, + 1, + } + data, err := dataCollector.Collect(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(expData).To(Equal(data)) + + Expect(data.Data).To(Equal(expData.Data)) + Expect(data.NGFResourceCounts).To(Equal(expData.NGFResourceCounts)) + Expect(data.ImageSource).To(Equal(expData.ImageSource)) + Expect(data.FlagNames).To(Equal(expData.FlagNames)) + Expect(data.FlagValues).To(Equal(expData.FlagValues)) + Expect(data.NGFReplicaCount).To(Equal(expData.NGFReplicaCount)) + Expect(data.SnippetsFiltersContextDirectives).To(Equal(expData.SnippetsFiltersContextDirectives)) + Expect(data.SnippetsFiltersContextDirectivesCount).To(Equal(expData.SnippetsFiltersContextDirectivesCount)) }) }) }) diff --git a/internal/mode/static/telemetry/data.avdl b/internal/mode/static/telemetry/data.avdl index 7c6afa847e..cfa86e94fa 100644 --- a/internal/mode/static/telemetry/data.avdl +++ b/internal/mode/static/telemetry/data.avdl @@ -91,5 +91,14 @@ attached at the Gateway level. */ /** NGFReplicaCount is the number of replicas of the NGF Pod. */ long? NGFReplicaCount = null; + /** SnippetsFiltersContextDirectives contains the context-directive strings of all applied SnippetsFilters. +Both lists are ordered first by count, then by lexicographical order on the context-directive string. */ + union {null, array} SnippetsFiltersContextDirectives = null; + + /** SnippetsFiltersContextDirectivesCount contains the count of the context-directive strings, where each count +corresponds to the string from SnippetsFiltersContextDirectives at the same index. Both lists are ordered +first by count, then by lexicographical order on the context-directive string. */ + union {null, array} SnippetsFiltersContextDirectivesCount = null; + } } diff --git a/internal/mode/static/telemetry/data_attributes_generated.go b/internal/mode/static/telemetry/data_attributes_generated.go index 8784b827f1..024f94feb2 100644 --- a/internal/mode/static/telemetry/data_attributes_generated.go +++ b/internal/mode/static/telemetry/data_attributes_generated.go @@ -19,6 +19,8 @@ func (d *Data) Attributes() []attribute.KeyValue { attrs = append(attrs, attribute.StringSlice("FlagValues", d.FlagValues)) attrs = append(attrs, d.NGFResourceCounts.Attributes()...) attrs = append(attrs, attribute.Int64("NGFReplicaCount", d.NGFReplicaCount)) + attrs = append(attrs, attribute.StringSlice("SnippetsFiltersContextDirectives", d.SnippetsFiltersContextDirectives)) + attrs = append(attrs, attribute.Int64Slice("SnippetsFiltersContextDirectivesCount", d.SnippetsFiltersContextDirectivesCount)) return attrs } diff --git a/internal/mode/static/telemetry/data_test.go b/internal/mode/static/telemetry/data_test.go index 8f33a85e41..8ea63e95a6 100644 --- a/internal/mode/static/telemetry/data_test.go +++ b/internal/mode/static/telemetry/data_test.go @@ -40,7 +40,9 @@ func TestDataAttributes(t *testing.T) { NginxProxyCount: 12, SnippetsFilterCount: 13, }, - NGFReplicaCount: 3, + NGFReplicaCount: 3, + SnippetsFiltersContextDirectives: []string{"main-three-count", "http-two-count", "server-one-count"}, + SnippetsFiltersContextDirectivesCount: []int64{3, 2, 1}, } expected := []attribute.KeyValue{ @@ -71,6 +73,11 @@ func TestDataAttributes(t *testing.T) { attribute.Int64("NginxProxyCount", 12), attribute.Int64("SnippetsFilterCount", 13), attribute.Int64("NGFReplicaCount", 3), + attribute.StringSlice( + "SnippetsFiltersContextDirectives", + []string{"main-three-count", "http-two-count", "server-one-count"}, + ), + attribute.IntSlice("SnippetsFiltersContextDirectivesCount", []int{3, 2, 1}), } result := data.Attributes() @@ -111,6 +118,8 @@ func TestDataAttributesWithEmptyData(t *testing.T) { attribute.Int64("NginxProxyCount", 0), attribute.Int64("SnippetsFilterCount", 0), attribute.Int64("NGFReplicaCount", 0), + attribute.StringSlice("SnippetsFiltersContextDirectives", nil), + attribute.IntSlice("SnippetsFiltersContextDirectivesCount", nil), } result := data.Attributes() diff --git a/site/content/overview/product-telemetry.md b/site/content/overview/product-telemetry.md index 536e4f8b45..42a9be6375 100644 --- a/site/content/overview/product-telemetry.md +++ b/site/content/overview/product-telemetry.md @@ -28,7 +28,7 @@ Telemetry data is collected once every 24 hours and sent to a service managed by - **Image Build Source:** whether the image was built by GitHub or locally (values are `gha`, `local`, or `unknown`). The source repository of the images is **not** collected. - **Deployment Flags:** a list of NGINX Gateway Fabric Deployment flags that are specified by a user. The actual values of non-boolean flags are **not** collected; we only record that they are either `true` or `false` for boolean flags and `default` or `user-defined` for the rest. - **Count of Resources:** the total count of resources related to NGINX Gateway Fabric. This includes `GatewayClasses`, `Gateways`, `HTTPRoutes`,`GRPCRoutes`, `TLSRoutes`, `Secrets`, `Services`, `BackendTLSPolicies`, `ClientSettingsPolicies`, `NginxProxies`, `ObservabilityPolicies`, `SnippetsFilters`, and `Endpoints`. The data within these resources is **not** collected. - +- **SnippetsFilters Info**a list of context-directive strings from applied SnippetFilters and a total count per strings. The actual value of any NGINX directive is **not** collected. This data is used to identify the following information: - The flavors of Kubernetes environments that are most popular among our users. diff --git a/tests/suite/telemetry_test.go b/tests/suite/telemetry_test.go index f2f887846c..7b0a070869 100644 --- a/tests/suite/telemetry_test.go +++ b/tests/suite/telemetry_test.go @@ -87,6 +87,8 @@ var _ = Describe("Telemetry test with OTel collector", Label("telemetry"), func( "NginxProxyCount: Int(0)", "SnippetsFilterCount: Int(0)", "NGFReplicaCount: Int(1)", + "SnippetsFiltersContextDirectives: Slice", + "SnippetsFiltersContextDirectivesCount: Slice", }, ) })