diff --git a/README.md b/README.md index 5576cbc2..84e39cca 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ Further Information | elasticsearch_indices_indexing_delete_total | counter | 1 | Total indexing deletes | elasticsearch_indices_indexing_index_time_seconds_total | counter | 1 | Cumulative index time in seconds | elasticsearch_indices_indexing_index_total | counter | 1 | Total index calls +| elasticsearch_indices_mappings_stats_fields | gauge | | Count of fields currently mapped by index | elasticsearch_indices_merges_docs_total | counter | 1 | Cumulative docs merged | elasticsearch_indices_merges_total | counter | 1 | Total merges | elasticsearch_indices_merges_total_size_bytes_total | counter | 1 | Total merge size in bytes @@ -151,6 +152,7 @@ Further Information | elasticsearch_indices_segments_count | gauge | 1 | Count of index segments on this node | elasticsearch_indices_segments_memory_bytes | gauge | 1 | Current memory size of segments in bytes | elasticsearch_indices_settings_stats_read_only_indices | gauge | 1 | Count of indices that have read_only_allow_delete=true +| elasticsearch_indices_settings_total_fields | gauge | | Index setting value for index.mapping.total_fields.limit (total allowable mapped fields in a index) | elasticsearch_indices_shards_docs | gauge | 3 | Count of documents on this shard | elasticsearch_indices_shards_docs_deleted | gauge | 3 | Count of deleted documents on each shard | elasticsearch_indices_store_size_bytes | gauge | 1 | Current size of stored index data in bytes diff --git a/collector/indices_mappings.go b/collector/indices_mappings.go new file mode 100644 index 00000000..dc5bab40 --- /dev/null +++ b/collector/indices_mappings.go @@ -0,0 +1,193 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path" + + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + defaultIndicesMappingsLabels = []string{"index"} +) + +type indicesMappingsMetric struct { + Type prometheus.ValueType + Desc *prometheus.Desc + Value func(indexMapping IndexMapping) float64 +} + +// IndicesMappings information struct +type IndicesMappings struct { + logger log.Logger + client *http.Client + url *url.URL + + up prometheus.Gauge + totalScrapes, jsonParseFailures prometheus.Counter + + metrics []*indicesMappingsMetric +} + +// NewIndicesMappings defines Indices IndexMappings Prometheus metrics +func NewIndicesMappings(logger log.Logger, client *http.Client, url *url.URL) *IndicesMappings { + subsystem := "indices_mappings_stats" + + return &IndicesMappings{ + logger: logger, + client: client, + url: url, + + up: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: prometheus.BuildFQName(namespace, subsystem, "up"), + Help: "Was the last scrape of the ElasticSearch Indices Mappings endpoint successful.", + }), + totalScrapes: prometheus.NewCounter(prometheus.CounterOpts{ + Name: prometheus.BuildFQName(namespace, subsystem, "scrapes_total"), + Help: "Current total ElasticSearch Indices Mappings scrapes.", + }), + jsonParseFailures: prometheus.NewCounter(prometheus.CounterOpts{ + Name: prometheus.BuildFQName(namespace, subsystem, "json_parse_failures_total"), + Help: "Number of errors while parsing JSON.", + }), + metrics: []*indicesMappingsMetric{ + { + Type: prometheus.GaugeValue, + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, subsystem, "fields"), + "Current number fields within cluster.", + defaultIndicesMappingsLabels, nil, + ), + Value: func(indexMapping IndexMapping) float64 { + return countFieldsRecursive(indexMapping.Mappings.Properties, 0) + }, + }, + }, + } +} + +func countFieldsRecursive(properties IndexMappingProperties, fieldCounter float64) float64 { + // iterate over all properties + for _, property := range properties { + if property.Type != nil { + // property has a type set - counts as a field + fieldCounter++ + + // iterate over all fields of that property + for _, field := range property.Fields { + // field has a type set - counts as a field + if field.Type != nil { + fieldCounter++ + } + } + } + + // count recursively in case the property has more properties + if property.Properties != nil { + fieldCounter = +countFieldsRecursive(property.Properties, fieldCounter) + } + } + + return fieldCounter +} + +// Describe add Snapshots metrics descriptions +func (im *IndicesMappings) Describe(ch chan<- *prometheus.Desc) { + for _, metric := range im.metrics { + ch <- metric.Desc + } + + ch <- im.up.Desc() + ch <- im.totalScrapes.Desc() + ch <- im.jsonParseFailures.Desc() +} + +func (im *IndicesMappings) getAndParseURL(u *url.URL) (*IndicesMappingsResponse, error) { + res, err := im.client.Get(u.String()) + if err != nil { + return nil, fmt.Errorf("failed to get from %s://%s:%s%s: %s", + u.Scheme, u.Hostname(), u.Port(), u.Path, err) + } + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP Request failed with code %d", res.StatusCode) + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + _ = level.Warn(im.logger).Log("msg", "failed to read response body", "err", err) + return nil, err + } + + err = res.Body.Close() + if err != nil { + _ = level.Warn(im.logger).Log("msg", "failed to close response body", "err", err) + return nil, err + } + + var imr IndicesMappingsResponse + if err := json.Unmarshal(body, &imr); err != nil { + im.jsonParseFailures.Inc() + return nil, err + } + + return &imr, nil +} + +func (im *IndicesMappings) fetchAndDecodeIndicesMappings() (*IndicesMappingsResponse, error) { + u := *im.url + u.Path = path.Join(u.Path, "/_all/_mappings") + return im.getAndParseURL(&u) +} + +// Collect gets all indices mappings metric values +func (im *IndicesMappings) Collect(ch chan<- prometheus.Metric) { + + im.totalScrapes.Inc() + defer func() { + ch <- im.up + ch <- im.totalScrapes + ch <- im.jsonParseFailures + }() + + indicesMappingsResponse, err := im.fetchAndDecodeIndicesMappings() + if err != nil { + im.up.Set(0) + _ = level.Warn(im.logger).Log( + "msg", "failed to fetch and decode cluster mappings stats", + "err", err, + ) + return + } + im.up.Set(1) + + for _, metric := range im.metrics { + for indexName, mappings := range *indicesMappingsResponse { + ch <- prometheus.MustNewConstMetric( + metric.Desc, + metric.Type, + metric.Value(mappings), + indexName, + ) + } + } +} diff --git a/collector/indices_mappings_response.go b/collector/indices_mappings_response.go new file mode 100644 index 00000000..3c54b3f7 --- /dev/null +++ b/collector/indices_mappings_response.go @@ -0,0 +1,47 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +// IndicesMappingsResponse is a representation of elasticsearch mappings for each index +type IndicesMappingsResponse map[string]IndexMapping + +// IndexMapping defines the struct of the tree for the mappings of each index +type IndexMapping struct { + Mappings IndexMappings `json:"mappings"` +} + +// IndexMappings defines all index mappings +type IndexMappings struct { + Properties IndexMappingProperties `json:"properties"` +} + +// IndexMappingProperties defines all the properties of the current mapping +type IndexMappingProperties map[string]*IndexMappingProperty + +// IndexMappingFields defines all the fields of the current mapping +type IndexMappingFields map[string]*IndexMappingField + +// IndexMappingProperty defines a single property of the current index properties +type IndexMappingProperty struct { + Type *string `json:"type"` + Properties IndexMappingProperties `json:"properties"` + Fields IndexMappingFields `json:"fields"` +} + +// IndexMappingField defines a single property of the current index field +type IndexMappingField struct { + Type *string `json:"type"` + Properties IndexMappingProperties `json:"properties"` + Fields IndexMappingFields `json:"fields"` +} diff --git a/collector/indices_mappings_test.go b/collector/indices_mappings_test.go new file mode 100644 index 00000000..3ef07038 --- /dev/null +++ b/collector/indices_mappings_test.go @@ -0,0 +1,161 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-kit/kit/log" +) + +func TestMapping(t *testing.T) { + // Testcases created using: + // docker run -p 9200:9200 -e "discovery.type=single-node" elasticsearch:7.8.0 + // curl -XPUT http://localhost:9200/twitter + // curl -XPUT http://localhost:9200/facebook + /* curl -XPUT http://localhost:9200/twitter/_mapping -H 'Content-Type: application/json' -d'{ + "properties": { + "email": { + "type": "keyword" + }, + "phone": { + "type": "keyword" + } + } + }'*/ + /* curl -XPUT http://localhost:9200/facebook/_mapping -H 'Content-Type: application/json' -d'{ + "properties": { + "name": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + } + }, + "contact": { + "properties": { + "email": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + } + }, + "phone": { + "type": "text" + } + } + } + } + }'*/ + // curl http://localhost:9200/_all/_mapping + tcs := map[string]string{ + "7.8.0": `{ + "facebook": { + "mappings": { + "properties": { + "contact": { + "properties": { + "email": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + } + }, + "phone": { + "type": "text" + } + } + }, + "name": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + } + } + } + } + }, + "twitter": { + "mappings": { + "properties": { + "email": { + "type": "keyword" + }, + "phone": { + "type": "keyword" + } + } + } + } + }`, + } + for ver, out := range tcs { + for hn, handler := range map[string]http.Handler{ + "plain": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, out) + }), + } { + ts := httptest.NewServer(handler) + defer ts.Close() + + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("Failed to parse URL: %s", err) + } + c := NewIndicesMappings(log.NewNopLogger(), http.DefaultClient, u) + imr, err := c.fetchAndDecodeIndicesMappings() + if err != nil { + t.Fatalf("Failed to fetch or decode indices mappings: %s", err) + } + t.Logf("[%s/%s] All Indices Mappings Response: %+v", hn, ver, imr) + + response := *imr + if *response["facebook"].Mappings.Properties["contact"].Properties["phone"].Type != "text" { + t.Errorf("Marshalling error at facebook.contact.phone") + } + + if *response["facebook"].Mappings.Properties["contact"].Properties["email"].Fields["raw"].Type != "keyword" { + t.Errorf("Marshalling error at facebook.contact.email.raw") + } + + if *response["facebook"].Mappings.Properties["name"].Type != "text" { + t.Errorf("Marshalling error at facebook.name") + } + + if *response["facebook"].Mappings.Properties["name"].Fields["raw"].Type != "keyword" { + t.Errorf("Marshalling error at facebook.name.raw") + } + + if *response["twitter"].Mappings.Properties["email"].Type != "keyword" { + t.Errorf("Marshalling error at twitter.email") + } + + if *response["twitter"].Mappings.Properties["phone"].Type != "keyword" { + t.Errorf("Marshalling error at twitter.phone") + } + + } + } +} diff --git a/collector/indices_settings.go b/collector/indices_settings.go index ad080d58..62790be8 100644 --- a/collector/indices_settings.go +++ b/collector/indices_settings.go @@ -19,6 +19,7 @@ import ( "net/http" "net/url" "path" + "strconv" "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" @@ -31,9 +32,22 @@ type IndicesSettings struct { client *http.Client url *url.URL - up prometheus.Gauge - readOnlyIndices prometheus.Gauge + up prometheus.Gauge + readOnlyIndices prometheus.Gauge + totalScrapes, jsonParseFailures prometheus.Counter + metrics []*indicesSettingsMetric +} + +var ( + defaultIndicesTotalFieldsLabels = []string{"index"} + defaultTotalFieldsValue = 1000 //es default configuration for total fields +) + +type indicesSettingsMetric struct { + Type prometheus.ValueType + Desc *prometheus.Desc + Value func(indexSettings Settings) float64 } // NewIndicesSettings defines Indices Settings Prometheus metrics @@ -59,6 +73,23 @@ func NewIndicesSettings(logger log.Logger, client *http.Client, url *url.URL) *I Name: prometheus.BuildFQName(namespace, "indices_settings_stats", "json_parse_failures"), Help: "Number of errors while parsing JSON.", }), + metrics: []*indicesSettingsMetric{ + { + Type: prometheus.GaugeValue, + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "indices_settings", "total_fields"), + "index mapping setting for total_fields", + defaultIndicesTotalFieldsLabels, nil, + ), + Value: func(indexSettings Settings) float64 { + val, err := strconv.ParseFloat(indexSettings.IndexInfo.Mapping.TotalFields.Limit, 10) + if err != nil { + return float64(defaultTotalFieldsValue) + } + return val + }, + }, + }, } } @@ -135,10 +166,18 @@ func (cs *IndicesSettings) Collect(ch chan<- prometheus.Metric) { cs.up.Set(1) var c int - for _, value := range asr { + for indexName, value := range asr { if value.Settings.IndexInfo.Blocks.ReadOnly == "true" { c++ } + for _, metric := range cs.metrics { + ch <- prometheus.MustNewConstMetric( + metric.Desc, + metric.Type, + metric.Value(value.Settings), + indexName, + ) + } } cs.readOnlyIndices.Set(float64(c)) } diff --git a/collector/indices_settings_response.go b/collector/indices_settings_response.go index 8a718004..b7350677 100644 --- a/collector/indices_settings_response.go +++ b/collector/indices_settings_response.go @@ -28,10 +28,21 @@ type Settings struct { // IndexInfo defines the blocks of the current index type IndexInfo struct { - Blocks Blocks `json:"blocks"` + Blocks Blocks `json:"blocks"` + Mapping Mapping `json:"mapping"` } // Blocks defines whether current index has read_only_allow_delete enabled type Blocks struct { ReadOnly string `json:"read_only_allow_delete"` } + +// Mapping defines mapping settings +type Mapping struct { + TotalFields TotalFields `json:"total_fields"` +} + +// TotalFields defines the limit on the number of mapped fields +type TotalFields struct { + Limit string `json:"limit"` +} diff --git a/collector/indices_settings_test.go b/collector/indices_settings_test.go index 959bf3eb..02812edd 100644 --- a/collector/indices_settings_test.go +++ b/collector/indices_settings_test.go @@ -33,6 +33,11 @@ func TestIndicesSettings(t *testing.T) { // curl -XPUT http://localhost:9200/instagram/_settings --header "Content-Type: application/json" -d ' // { // "index": { + // "mapping": { + // "total_fields": { + // "limit": 10000 + // } + // }, // "blocks": { // "read_only_allow_delete": "true" // } @@ -50,7 +55,7 @@ func TestIndicesSettings(t *testing.T) { // curl http://localhost:9200/_all/_settings tcs := map[string]string{ - "6.5.4": `{"viber":{"settings":{"index":{"creation_date":"1548066996192","number_of_shards":"5","number_of_replicas":"1","uuid":"kt2cGV-yQRaloESpqj2zsg","version":{"created":"6050499"},"provided_name":"viber"}}},"facebook":{"settings":{"index":{"creation_date":"1548066984670","number_of_shards":"5","number_of_replicas":"1","uuid":"jrU8OWQZQD--9v5eg0tjbg","version":{"created":"6050499"},"provided_name":"facebook"}}},"twitter":{"settings":{"index":{"number_of_shards":"5","blocks":{"read_only_allow_delete":"true"},"provided_name":"twitter","creation_date":"1548066697559","number_of_replicas":"1","uuid":"-sqtc4fVRrS2jHJCZ2hQ9Q","version":{"created":"6050499"}}}},"instagram":{"settings":{"index":{"number_of_shards":"5","blocks":{"read_only_allow_delete":"true"},"provided_name":"instagram","creation_date":"1548066991932","number_of_replicas":"1","uuid":"WeGWaxa_S3KrgE5SZHolTw","version":{"created":"6050499"}}}}}`, + "6.5.4": `{"viber":{"settings":{"index":{"creation_date":"1618593207186","number_of_shards":"5","number_of_replicas":"1","uuid":"lWg86KTARzO3r7lELytT1Q","version":{"created":"6050499"},"provided_name":"viber"}}},"instagram":{"settings":{"index":{"mapping":{"total_fields":{"limit":"10000"}},"number_of_shards":"5","blocks":{"read_only_allow_delete":"true"},"provided_name":"instagram","creation_date":"1618593203353","number_of_replicas":"1","uuid":"msb6eG7aT8GmNe-a4oyVtQ","version":{"created":"6050499"}}}},"twitter":{"settings":{"index":{"number_of_shards":"5","blocks":{"read_only_allow_delete":"true"},"provided_name":"twitter","creation_date":"1618593193641","number_of_replicas":"1","uuid":"YRUT8t4aSkKsNmGl7K3y4Q","version":{"created":"6050499"}}}},"facebook":{"settings":{"index":{"creation_date":"1618593199101","number_of_shards":"5","number_of_replicas":"1","uuid":"trZhb_YOTV-RWKitTYw81A","version":{"created":"6050499"},"provided_name":"facebook"}}}}`, } for ver, out := range tcs { for hn, handler := range map[string]http.Handler{ @@ -75,6 +80,7 @@ func TestIndicesSettings(t *testing.T) { // t.Errorf("Wrong setting for cluster routing allocation enabled") // } var counter int + var total_fields int for key, value := range nsr { if value.Settings.IndexInfo.Blocks.ReadOnly == "true" { counter++ @@ -82,10 +88,19 @@ func TestIndicesSettings(t *testing.T) { t.Errorf("Wrong read_only index") } } + if value.Settings.IndexInfo.Mapping.TotalFields.Limit == "10000" { + total_fields++ + if key != "instagram" { + t.Errorf("Expected 10000 total_fields only for instagram") + } + } } if counter != 2 { t.Errorf("Wrong number of read_only indexes") } + if total_fields != 1 { + t.Errorf(("Wrong number of total_fields found")) + } } } } diff --git a/main.go b/main.go index b988f128..878fa23e 100644 --- a/main.go +++ b/main.go @@ -58,6 +58,9 @@ func main() { esExportIndicesSettings = kingpin.Flag("es.indices_settings", "Export stats for settings of all indices of the cluster."). Default("false").Envar("ES_INDICES_SETTINGS").Bool() + esExportIndicesMappings = kingpin.Flag("es.indices_mappings", + "Export stats for mappings of all indices of the cluster."). + Default("false").Envar("ES_INDICES_MAPPINGS").Bool() esExportClusterSettings = kingpin.Flag("es.cluster_settings", "Export stats for cluster settings."). Default("false").Envar("ES_CLUSTER_SETTINGS").Bool() @@ -150,6 +153,10 @@ func main() { prometheus.MustRegister(collector.NewIndicesSettings(logger, httpClient, esURL)) } + if *esExportIndicesMappings { + prometheus.MustRegister(collector.NewIndicesMappings(logger, httpClient, esURL)) + } + // create a http server server := &http.Server{}