diff --git a/internal/mode/static/telemetry/collector.go b/internal/mode/static/telemetry/collector.go index bcfd6fe283..a9defb5872 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,17 @@ 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 + // SnippetsFiltersDirectives contains the directive-context strings of all applied SnippetsFilters. + // Both lists are ordered first by count, then by lexicographical order of the context string, + // then lastly by directive string. + SnippetsFiltersDirectives []string + // SnippetsFiltersDirectivesCount contains the count of the directive-context strings, where each count + // corresponds to the string from SnippetsFiltersDirectives at the same index. + // Both lists are ordered first by count, then by lexicographical order of the context string, + // then lastly by directive string. + SnippetsFiltersDirectivesCount []int64 + NGFResourceCounts // embedding is required by the generator. // NGFReplicaCount is the number of replicas of the NGF Pod. NGFReplicaCount int64 } @@ -83,6 +95,8 @@ type NGFResourceCounts struct { ObservabilityPolicyCount int64 // NginxProxyCount is the number of NginxProxies. NginxProxyCount int64 + // SnippetsFilterCount is the number of SnippetsFilters. + SnippetsFilterCount int64 } // DataCollectorConfig holds configuration parameters for DataCollectorImpl. @@ -119,12 +133,17 @@ func NewDataCollectorImpl( // Collect collects and returns telemetry Data. func (c DataCollectorImpl) Collect(ctx context.Context) (Data, error) { + g := c.cfg.GraphGetter.GetLatestGraph() + if g == nil { + return Data{}, errors.New("failed to collect telemetry data: latest graph cannot be nil") + } + clusterInfo, err := collectClusterInformation(ctx, c.cfg.K8sClientReader) if err != nil { return Data{}, fmt.Errorf("failed to collect cluster information: %w", err) } - graphResourceCount, err := collectGraphResourceCount(c.cfg.GraphGetter, c.cfg.ConfigurationGetter) + graphResourceCount, err := collectGraphResourceCount(g, c.cfg.ConfigurationGetter) if err != nil { return Data{}, fmt.Errorf("failed to collect NGF resource counts: %w", err) } @@ -144,6 +163,8 @@ func (c DataCollectorImpl) Collect(ctx context.Context) (Data, error) { return Data{}, fmt.Errorf("failed to get NGF deploymentID: %w", err) } + snippetsFiltersDirectives, snippetsFiltersDirectivesCount := collectSnippetsFilterDirectives(g) + data := Data{ Data: tel.Data{ ProjectName: "NGF", @@ -155,27 +176,25 @@ func (c DataCollectorImpl) Collect(ctx context.Context) (Data, error) { InstallationID: deploymentID, ClusterNodeCount: int64(clusterInfo.NodeCount), }, - NGFResourceCounts: graphResourceCount, - ImageSource: c.cfg.ImageSource, - FlagNames: c.cfg.Flags.Names, - FlagValues: c.cfg.Flags.Values, - NGFReplicaCount: int64(replicaCount), + NGFResourceCounts: graphResourceCount, + ImageSource: c.cfg.ImageSource, + FlagNames: c.cfg.Flags.Names, + FlagValues: c.cfg.Flags.Values, + NGFReplicaCount: int64(replicaCount), + SnippetsFiltersDirectives: snippetsFiltersDirectives, + SnippetsFiltersDirectivesCount: snippetsFiltersDirectivesCount, } return data, nil } func collectGraphResourceCount( - graphGetter GraphGetter, + g *graph.Graph, configurationGetter ConfigurationGetter, ) (NGFResourceCounts, error) { ngfResourceCounts := NGFResourceCounts{} - g := graphGetter.GetLatestGraph() cfg := configurationGetter.GetLatestConfiguration() - if g == nil { - return ngfResourceCounts, errors.New("latest graph cannot be nil") - } if cfg == nil { return ngfResourceCounts, errors.New("latest configuration cannot be nil") } @@ -227,6 +246,8 @@ func collectGraphResourceCount( ngfResourceCounts.NginxProxyCount = 1 } + ngfResourceCounts.SnippetsFilterCount = int64(len(g.SnippetsFilters)) + return ngfResourceCounts, nil } @@ -378,3 +399,103 @@ func collectClusterInformation(ctx context.Context, k8sClient client.Reader) (cl return clusterInfo, nil } + +type sfDirectiveContext struct { + directive string + context string +} + +func collectSnippetsFilterDirectives(g *graph.Graph) ([]string, []int64) { + directiveContextMap := make(map[sfDirectiveContext]int) + + for _, sf := range g.SnippetsFilters { + if sf == nil { + continue + } + + for nginxContext, snippetValue := range sf.Snippets { + 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 { + directiveContext := sfDirectiveContext{ + directive: directive, + context: parsedContext, + } + directiveContextMap[directiveContext]++ + } + } + } + + return parseDirectiveContextMapIntoLists(directiveContextMap) +} + +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 +} + +// parseDirectiveContextMapIntoLists returns two same-length lists where the elements at each corresponding index +// are paired together. +// The first list contains strings which are the NGINX directive and context of a Snippet joined with a hyphen. +// The second list contains ints which are the count of total same directive-context values of the first list. +// Both lists are ordered first by count, then by lexicographical order of the context string, +// then lastly by directive string. +func parseDirectiveContextMapIntoLists(directiveContextMap map[sfDirectiveContext]int) ([]string, []int64) { + type sfDirectiveContextCount struct { + directive, context string + count int64 + } + + kvPairs := make([]sfDirectiveContextCount, 0, len(directiveContextMap)) + + for k, v := range directiveContextMap { + kvPairs = append(kvPairs, sfDirectiveContextCount{k.directive, k.context, int64(v)}) + } + + sort.Slice(kvPairs, func(i, j int) bool { + if kvPairs[i].count == kvPairs[j].count { + if kvPairs[i].context == kvPairs[j].context { + return kvPairs[i].directive < kvPairs[j].directive + } + return kvPairs[i].context < kvPairs[j].context + } + return kvPairs[i].count > kvPairs[j].count + }) + + directiveContextList := make([]string, len(kvPairs)) + countList := make([]int64, len(kvPairs)) + + for i, pair := range kvPairs { + directiveContextList[i] = pair.directive + "-" + pair.context + countList[i] = pair.count + } + + return directiveContextList, countList +} diff --git a/internal/mode/static/telemetry/collector_test.go b/internal/mode/static/telemetry/collector_test.go index 4175524ff3..59bee05aae 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, + SnippetsFiltersDirectives: []string{}, + SnippetsFiltersDirectivesCount: []int64{}, } k8sClientReader = &eventsfakes.FakeReader{} @@ -328,6 +331,35 @@ var _ = Describe("Collector", Ordered, func() { }: {}, }, NginxProxy: &graph.NginxProxy{}, + SnippetsFilters: map[types.NamespacedName]*graph.SnippetsFilter{ + {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"}: { + Snippets: map[ngfAPI.NginxContext]string{ + // Tests lexicographical ordering when count and context is the same + ngfAPI.NginxContextMain: "worker_rlimit_core 1m;", + ngfAPI.NginxContextHTTPServer: "auth_delay 10s;", + }, + }, + }, } config := &dataplane.Configuration{ @@ -379,14 +411,38 @@ var _ = Describe("Collector", Ordered, func() { RouteAttachedClientSettingsPolicyCount: 2, ObservabilityPolicyCount: 1, NginxProxyCount: 1, + SnippetsFilterCount: 3, } expData.ClusterVersion = "1.29.2" expData.ClusterPlatform = "kind" - data, err := dataCollector.Collect(ctx) + expData.SnippetsFiltersDirectives = []string{ + "auth_delay-server", + "aio-http", + "keepalive_time-location", + "worker_priority-main", + "client_body_timeout-http", + "allow-location", + "worker_rlimit_core-main", + "worker_rlimit_nofile-main", + "ignore_invalid_headers-server", + } + expData.SnippetsFiltersDirectivesCount = []int64{ + 3, + 2, + 2, + 2, + 1, + 1, + 1, + 1, + 1, + } + data, err := dataCollector.Collect(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(expData).To(Equal(data)) + + Expect(data).To(Equal(expData)) }) }) }) @@ -549,6 +605,9 @@ var _ = Describe("Collector", Ordered, func() { }: {}, }, NginxProxy: &graph.NginxProxy{}, + SnippetsFilters: map[types.NamespacedName]*graph.SnippetsFilter{ + {Namespace: "test", Name: "sf-1"}: {}, + }, } config1 = &dataplane.Configuration{ @@ -622,6 +681,7 @@ var _ = Describe("Collector", Ordered, func() { RouteAttachedClientSettingsPolicyCount: 1, ObservabilityPolicyCount: 1, NginxProxyCount: 1, + SnippetsFilterCount: 1, } data, err := dataCollector.Collect(ctx) @@ -647,6 +707,7 @@ var _ = Describe("Collector", Ordered, func() { RouteAttachedClientSettingsPolicyCount: 0, ObservabilityPolicyCount: 0, NginxProxyCount: 0, + SnippetsFilterCount: 0, } data, err := dataCollector.Collect(ctx) @@ -661,7 +722,7 @@ var _ = Describe("Collector", Ordered, func() { fakeConfigurationGetter.GetLatestConfigurationReturns(&dataplane.Configuration{}) }) It("should error on nil latest graph", func(ctx SpecContext) { - expectedError := errors.New("latest graph cannot be nil") + expectedError := errors.New("failed to collect telemetry data: latest graph cannot be nil") fakeGraphGetter.GetLatestGraphReturns(nil) _, err := dataCollector.Collect(ctx) @@ -857,4 +918,44 @@ var _ = Describe("Collector", Ordered, func() { }) }) }) + + Describe("snippetsFilters collector", func() { + When("collecting snippetsFilters data", func() { + It("collects correct data for nil snippetsFilters", func(ctx SpecContext) { + fakeGraphGetter.GetLatestGraphReturns(&graph.Graph{ + SnippetsFilters: map[types.NamespacedName]*graph.SnippetsFilter{ + {Namespace: "test", Name: "sf-1"}: nil, + }, + }) + + expData.SnippetsFilterCount = 1 + + data, err := dataCollector.Collect(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal(expData)) + }) + + It("collects correct data when snippetsFilters context is not supported", func(ctx SpecContext) { + fakeGraphGetter.GetLatestGraphReturns(&graph.Graph{ + SnippetsFilters: map[types.NamespacedName]*graph.SnippetsFilter{ + {Namespace: "test", Name: "sf-1"}: { + Snippets: map[ngfAPI.NginxContext]string{ + "unsupportedContext": "worker_priority 0;", + }, + }, + }, + }) + + expData.SnippetsFilterCount = 1 + expData.SnippetsFiltersDirectives = []string{"worker_priority-unknown"} + expData.SnippetsFiltersDirectivesCount = []int64{1} + + data, err := dataCollector.Collect(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal(expData)) + }) + }) + }) }) diff --git a/internal/mode/static/telemetry/data.avdl b/internal/mode/static/telemetry/data.avdl index 097a9bd686..71515e0e53 100644 --- a/internal/mode/static/telemetry/data.avdl +++ b/internal/mode/static/telemetry/data.avdl @@ -45,6 +45,17 @@ at the same index. Each value is either 'true' or 'false' for boolean flags and 'default' or 'user-defined' for non-boolean flags. */ union {null, array} FlagValues = null; + /** SnippetsFiltersDirectives contains the directive-context strings of all applied SnippetsFilters. +Both lists are ordered first by count, then by lexicographical order of the context string, +then lastly by directive string. */ + union {null, array} SnippetsFiltersDirectives = null; + + /** SnippetsFiltersDirectivesCount contains the count of the directive-context strings, where each count +corresponds to the string from SnippetsFiltersDirectives at the same index. +Both lists are ordered first by count, then by lexicographical order of the context string, +then lastly by directive string. */ + union {null, array} SnippetsFiltersDirectivesCount = null; + /** GatewayCount is the number of relevant Gateways. */ long? GatewayCount = null; @@ -85,6 +96,9 @@ attached at the Gateway level. */ /** NginxProxyCount is the number of NginxProxies. */ long? NginxProxyCount = null; + /** SnippetsFilterCount is the number of SnippetsFilters. */ + long? SnippetsFilterCount = null; + /** NGFReplicaCount is the number of replicas of the NGF Pod. */ long? NGFReplicaCount = null; diff --git a/internal/mode/static/telemetry/data_attributes_generated.go b/internal/mode/static/telemetry/data_attributes_generated.go index 8784b827f1..da061512a3 100644 --- a/internal/mode/static/telemetry/data_attributes_generated.go +++ b/internal/mode/static/telemetry/data_attributes_generated.go @@ -17,6 +17,8 @@ func (d *Data) Attributes() []attribute.KeyValue { attrs = append(attrs, d.Data.Attributes()...) attrs = append(attrs, attribute.StringSlice("FlagNames", d.FlagNames)) attrs = append(attrs, attribute.StringSlice("FlagValues", d.FlagValues)) + attrs = append(attrs, attribute.StringSlice("SnippetsFiltersDirectives", d.SnippetsFiltersDirectives)) + attrs = append(attrs, attribute.Int64Slice("SnippetsFiltersDirectivesCount", d.SnippetsFiltersDirectivesCount)) attrs = append(attrs, d.NGFResourceCounts.Attributes()...) attrs = append(attrs, attribute.Int64("NGFReplicaCount", d.NGFReplicaCount)) diff --git a/internal/mode/static/telemetry/data_test.go b/internal/mode/static/telemetry/data_test.go index 893203fd9e..cb8f084b39 100644 --- a/internal/mode/static/telemetry/data_test.go +++ b/internal/mode/static/telemetry/data_test.go @@ -38,8 +38,11 @@ func TestDataAttributes(t *testing.T) { RouteAttachedClientSettingsPolicyCount: 10, ObservabilityPolicyCount: 11, NginxProxyCount: 12, + SnippetsFilterCount: 13, }, - NGFReplicaCount: 3, + NGFReplicaCount: 3, + SnippetsFiltersDirectives: []string{"main-three-count", "http-two-count", "server-one-count"}, + SnippetsFiltersDirectivesCount: []int64{3, 2, 1}, } expected := []attribute.KeyValue{ @@ -55,6 +58,11 @@ func TestDataAttributes(t *testing.T) { attribute.Int64("ClusterNodeCount", 3), attribute.StringSlice("FlagNames", []string{"test-flag"}), attribute.StringSlice("FlagValues", []string{"test-value"}), + attribute.StringSlice( + "SnippetsFiltersDirectives", + []string{"main-three-count", "http-two-count", "server-one-count"}, + ), + attribute.IntSlice("SnippetsFiltersDirectivesCount", []int{3, 2, 1}), attribute.Int64("GatewayCount", 1), attribute.Int64("GatewayClassCount", 2), attribute.Int64("HTTPRouteCount", 3), @@ -68,6 +76,7 @@ func TestDataAttributes(t *testing.T) { attribute.Int64("RouteAttachedClientSettingsPolicyCount", 10), attribute.Int64("ObservabilityPolicyCount", 11), attribute.Int64("NginxProxyCount", 12), + attribute.Int64("SnippetsFilterCount", 13), attribute.Int64("NGFReplicaCount", 3), } @@ -94,6 +103,8 @@ func TestDataAttributesWithEmptyData(t *testing.T) { attribute.Int64("ClusterNodeCount", 0), attribute.StringSlice("FlagNames", nil), attribute.StringSlice("FlagValues", nil), + attribute.StringSlice("SnippetsFiltersDirectives", nil), + attribute.IntSlice("SnippetsFiltersDirectivesCount", nil), attribute.Int64("GatewayCount", 0), attribute.Int64("GatewayClassCount", 0), attribute.Int64("HTTPRouteCount", 0), @@ -107,6 +118,7 @@ func TestDataAttributesWithEmptyData(t *testing.T) { attribute.Int64("RouteAttachedClientSettingsPolicyCount", 0), attribute.Int64("ObservabilityPolicyCount", 0), attribute.Int64("NginxProxyCount", 0), + attribute.Int64("SnippetsFilterCount", 0), attribute.Int64("NGFReplicaCount", 0), } diff --git a/internal/mode/static/telemetry/ngfresourcecounts_attributes_generated.go b/internal/mode/static/telemetry/ngfresourcecounts_attributes_generated.go index 2aa085405f..318cbd0b62 100644 --- a/internal/mode/static/telemetry/ngfresourcecounts_attributes_generated.go +++ b/internal/mode/static/telemetry/ngfresourcecounts_attributes_generated.go @@ -25,6 +25,7 @@ func (d *NGFResourceCounts) Attributes() []attribute.KeyValue { attrs = append(attrs, attribute.Int64("RouteAttachedClientSettingsPolicyCount", d.RouteAttachedClientSettingsPolicyCount)) attrs = append(attrs, attribute.Int64("ObservabilityPolicyCount", d.ObservabilityPolicyCount)) attrs = append(attrs, attribute.Int64("NginxProxyCount", d.NginxProxyCount)) + attrs = append(attrs, attribute.Int64("SnippetsFilterCount", d.SnippetsFilterCount)) return attrs } diff --git a/site/content/overview/product-telemetry.md b/site/content/overview/product-telemetry.md index cb13b6997a..237abb1303 100644 --- a/site/content/overview/product-telemetry.md +++ b/site/content/overview/product-telemetry.md @@ -27,8 +27,8 @@ Telemetry data is collected once every 24 hours and sent to a service managed by - **Deployment Replica Count:** the count of NGINX Gateway Fabric Pods. - **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`, and `Endpoints`. The data within these resources is **not** collected. - +- **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 directive-context 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 a88cfd3ee0..ac8f84267c 100644 --- a/tests/suite/telemetry_test.go +++ b/tests/suite/telemetry_test.go @@ -72,6 +72,8 @@ var _ = Describe("Telemetry test with OTel collector", Label("telemetry"), func( fmt.Sprintf("ClusterNodeCount: Int(%d)", info.NodeCount), "FlagNames: Slice", "FlagValues: Slice", + "SnippetsFiltersDirectives: Slice", + "SnippetsFiltersDirectivesCount: Slice", "GatewayCount: Int(0)", "GatewayClassCount: Int(1)", "HTTPRouteCount: Int(0)", @@ -85,6 +87,7 @@ var _ = Describe("Telemetry test with OTel collector", Label("telemetry"), func( "RouteAttachedClientSettingsPolicyCount: Int(0)", "ObservabilityPolicyCount: Int(0)", "NginxProxyCount: Int(0)", + "SnippetsFilterCount: Int(0)", "NGFReplicaCount: Int(1)", }, )