logstash-filter-opensearch-manticore 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,52 @@
1
+ # Copyright OpenSearch Contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ require 'opensearch'
5
+ require 'opensearch/transport/transport/connections/selector'
6
+
7
+ # elasticsearch-transport versions prior to 7.2.0 suffered of a race condition on accessing
8
+ # the connection pool. This issue was fixed (in 7.2.0) with
9
+ # https://github.com/elastic/elasticsearch-ruby/commit/15f9d78591a6e8823948494d94b15b0ca38819d1
10
+ #
11
+ # This plugin, at the moment, is using elasticsearch >= 5.0.5
12
+ # When this requirement ceases, this patch could be removed.
13
+ module OpenSearch
14
+ module Transport
15
+ module Transport
16
+ module Connections
17
+ module Selector
18
+
19
+ # "Round-robin" selector strategy (default).
20
+ #
21
+ class RoundRobin
22
+ include Base
23
+
24
+ # @option arguments [Connections::Collection] :connections Collection with connections.
25
+ #
26
+ def initialize(arguments = {})
27
+ super
28
+ @mutex = Mutex.new
29
+ @current = nil
30
+ end
31
+
32
+ # Returns the next connection from the collection, rotating them in round-robin fashion.
33
+ #
34
+ # @return [Connections::Connection]
35
+ #
36
+ def select(options={})
37
+ @mutex.synchronize do
38
+ conns = connections
39
+ if @current && (@current < conns.size-1)
40
+ @current += 1
41
+ else
42
+ @current = 0
43
+ end
44
+ conns[@current]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,44 @@
1
+ # Copyright OpenSearch Contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # encoding: utf-8
5
+ require "opensearch"
6
+ require "opensearch/transport/transport/http/manticore"
7
+
8
+
9
+ # elasticsearch-transport 7.2.0 - 7.14.0 had a bug where setting http headers
10
+ # ES::Client.new ..., transport_options: { headers: { 'Authorization' => ... } }
11
+ # would be lost https://github.com/elastic/elasticsearch-ruby/issues/1428
12
+ #
13
+ # NOTE: needs to be idempotent as filter OpenSearch plugin might apply the same patch!
14
+ #
15
+ # @private
16
+ module OpenSearch
17
+ module Transport
18
+ module Transport
19
+ module HTTP
20
+ class Manticore
21
+
22
+ def apply_headers(request_options, options)
23
+ headers = (options && options[:headers]) || {}
24
+ headers[CONTENT_TYPE_STR] = find_value(headers, CONTENT_TYPE_REGEX) || DEFAULT_CONTENT_TYPE
25
+
26
+ # this code is necessary to grab the correct user-agent header
27
+ # when this method is invoked with apply_headers(@request_options, options)
28
+ # from https://github.com/opensearch-project/opensearch-ruby/blob/main/opensearch-transport/lib/opensearch/transport/transport/http/manticore.rb#L122-L123
29
+ transport_user_agent = nil
30
+ if (options && options[:transport_options] && options[:transport_options][:headers])
31
+ transport_headers = options[:transport_options][:headers]
32
+ transport_user_agent = find_value(transport_headers, USER_AGENT_REGEX)
33
+ end
34
+
35
+ headers[USER_AGENT_STR] = transport_user_agent || find_value(headers, USER_AGENT_REGEX) || user_agent_header
36
+ headers[ACCEPT_ENCODING] = GZIP if use_compression?
37
+ (request_options[:headers] ||= {}).merge!(headers) # this line was changed
38
+ end
39
+
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,281 @@
1
+ # encoding: utf-8
2
+ require "logstash/filters/base"
3
+ require "logstash/namespace"
4
+ require "logstash/json"
5
+ require "logstash/util/safe_uri"
6
+ java_import "java.util.concurrent.ConcurrentHashMap"
7
+ require "opensearch"
8
+ require "opensearch/transport/transport/http/manticore"
9
+ require_relative "opensearch/patches/_opensearch_transport_http_manticore"
10
+ require_relative "opensearch/patches/_opensearch_transport_connections_selector"
11
+
12
+
13
+ class LogStash::Filters::OpenSearch < LogStash::Filters::Base
14
+ config_name "opensearch"
15
+
16
+ DEFAULT_HOST = ::LogStash::Util::SafeURI.new("//localhost:9200")
17
+
18
+ # List of opensearch hosts to use for querying.
19
+ config :hosts, :validate => :array, :default => [ DEFAULT_HOST ]
20
+
21
+ # Comma-delimited list of index names to search; use `_all` or empty string to perform the operation on all indices.
22
+ # Field substitution (e.g. `index-name-%{date_field}`) is available
23
+ config :index, :validate => :string, :default => ""
24
+
25
+ # OpenSearch query string. Read the OpenSearch query string documentation.
26
+ # for more info at: https://www.elastic.co/guide/en/opensearch/reference/master/query-dsl-query-string-query.html#query-string-syntax
27
+ config :query, :validate => :string
28
+
29
+ # File path to opensearch query in DSL format. Read the OpenSearch query documentation
30
+ # for more info at: https://www.elastic.co/guide/en/opensearch/reference/current/query-dsl.html
31
+ config :query_template, :validate => :string
32
+
33
+ # Comma-delimited list of `<field>:<direction>` pairs that define the sort order
34
+ config :sort, :validate => :string, :default => "@timestamp:desc"
35
+
36
+ # Array of fields to copy from old event (found via opensearch) into new event
37
+ config :fields, :validate => :array, :default => {}
38
+
39
+ # Hash of docinfo fields to copy from old event (found via opensearch) into new event
40
+ config :docinfo_fields, :validate => :hash, :default => {}
41
+
42
+ # Hash of aggregation names to copy from opensearch response into Logstash event fields
43
+ config :aggregation_fields, :validate => :hash, :default => {}
44
+
45
+ # Basic Auth - username
46
+ config :user, :validate => :string
47
+
48
+ # Basic Auth - password
49
+ config :password, :validate => :password
50
+
51
+ # Cloud ID, from the Elastic Cloud web console. If set `hosts` should not be used.
52
+ #
53
+ # For more info, check out the https://www.elastic.co/guide/en/logstash/current/connecting-to-cloud.html#_cloud_id[Logstash-to-Cloud documentation]
54
+ config :cloud_id, :validate => :string
55
+
56
+ # Cloud authentication string ("<username>:<password>" format) is an alternative for the `user`/`password` configuration.
57
+ #
58
+ # For more info, check out the https://www.elastic.co/guide/en/logstash/current/connecting-to-cloud.html#_cloud_auth[Logstash-to-Cloud documentation]
59
+ config :cloud_auth, :validate => :password
60
+
61
+ # Authenticate using OpenSearch API key.
62
+ # format is id:api_key (as returned by https://www.elastic.co/guide/en/opensearch/reference/current/security-api-create-api-key.html[Create API key])
63
+ config :api_key, :validate => :password
64
+
65
+ # SSL
66
+ config :ssl, :validate => :boolean, :default => false
67
+
68
+ # SSL Certificate Authority file
69
+ config :ca_file, :validate => :path
70
+
71
+ # Whether results should be sorted or not
72
+ config :enable_sort, :validate => :boolean, :default => true
73
+
74
+ # How many results to return
75
+ config :result_size, :validate => :number, :default => 1
76
+
77
+ # Tags the event on failure to look up geo information. This can be used in later analysis.
78
+ config :tag_on_failure, :validate => :array, :default => ["_opensearch_lookup_failure"]
79
+
80
+ attr_reader :clients_pool
81
+
82
+ def register
83
+ @clients_pool = java.util.concurrent.ConcurrentHashMap.new
84
+
85
+ #Load query if it exists
86
+ if @query_template
87
+ if File.zero?(@query_template)
88
+ raise "template is empty"
89
+ end
90
+ file = File.open(@query_template, 'r')
91
+ @query_dsl = file.read
92
+ end
93
+
94
+ validate_authentication
95
+
96
+ @transport_options = {:headers => {}}
97
+ @transport_options[:headers].merge!(setup_basic_auth(user, password))
98
+ @transport_options[:headers].merge!({'user-agent' => prepare_user_agent })
99
+ @transport_options[:request_timeout] = @request_timeout_seconds unless @request_timeout_seconds.nil?
100
+ @transport_options[:connect_timeout] = @connect_timeout_seconds unless @connect_timeout_seconds.nil?
101
+ @transport_options[:socket_timeout] = @socket_timeout_seconds unless @socket_timeout_seconds.nil?
102
+
103
+ @hosts = setup_hosts
104
+ @ssl_options = setup_ssl
105
+
106
+ test_connection!
107
+ end # def register
108
+
109
+ def filter(event)
110
+ matched = false
111
+ begin
112
+ params = {:index => event.sprintf(@index) }
113
+
114
+ if @query_dsl
115
+ query = LogStash::Json.load(event.sprintf(@query_dsl))
116
+ params[:body] = query
117
+ else
118
+ query = event.sprintf(@query)
119
+ params[:q] = query
120
+ params[:size] = result_size
121
+ params[:sort] = @sort if @enable_sort
122
+ end
123
+
124
+ @logger.debug("Querying opensearch for lookup", :params => params)
125
+
126
+ results = get_client.search(params)
127
+ raise "OpenSearch query error: #{results["_shards"]["failures"]}" if results["_shards"].include? "failures"
128
+
129
+ event.set("[@metadata][total_hits]", extract_total_from_hits(results['hits']))
130
+
131
+ resultsHits = results["hits"]["hits"]
132
+ if !resultsHits.nil? && !resultsHits.empty?
133
+ matched = true
134
+ @fields.each do |old_key, new_key|
135
+ old_key_path = extract_path(old_key)
136
+ set = resultsHits.map do |doc|
137
+ extract_value(doc["_source"], old_key_path)
138
+ end
139
+ event.set(new_key, set.count > 1 ? set : set.first)
140
+ end
141
+ @docinfo_fields.each do |old_key, new_key|
142
+ old_key_path = extract_path(old_key)
143
+ set = resultsHits.map do |doc|
144
+ extract_value(doc, old_key_path)
145
+ end
146
+ event.set(new_key, set.count > 1 ? set : set.first)
147
+ end
148
+ end
149
+
150
+ resultsAggs = results["aggregations"]
151
+ if !resultsAggs.nil? && !resultsAggs.empty?
152
+ matched = true
153
+ @aggregation_fields.each do |agg_name, ls_field|
154
+ event.set(ls_field, resultsAggs[agg_name])
155
+ end
156
+ end
157
+
158
+ rescue => e
159
+ if @logger.trace?
160
+ @logger.warn("Failed to query opensearch for previous event", :index => @index, :query => query, :event => event.to_hash, :error => e.message, :backtrace => e.backtrace)
161
+ elsif @logger.debug?
162
+ @logger.warn("Failed to query opensearch for previous event", :index => @index, :error => e.message, :backtrace => e.backtrace)
163
+ else
164
+ @logger.warn("Failed to query opensearch for previous event", :index => @index, :error => e.message)
165
+ end
166
+ @tag_on_failure.each{|tag| event.tag(tag)}
167
+ else
168
+ filter_matched(event) if matched
169
+ end
170
+ end # def filter
171
+
172
+ private
173
+
174
+ def new_client
175
+ # NOTE: could pass cloud-id/cloud-auth to client but than we would need to be stricter on ES version requirement
176
+ # and also LS parsing might differ from ES client's parsing so for consistency we do not pass cloud options ...
177
+ OpenSearch::Client.new(
178
+ :hosts => @hosts,
179
+ :transport_options => @transport_options,
180
+ :transport_class => ::OpenSearch::Transport::Transport::HTTP::Manticore,
181
+ :ssl => @ssl_options
182
+ )
183
+ end
184
+
185
+ def get_client
186
+ @clients_pool.computeIfAbsent(Thread.current, lambda { |x| new_client })
187
+ end
188
+
189
+ # get an array of path elements from a path reference
190
+ def extract_path(path_reference)
191
+ return [path_reference] unless path_reference.start_with?('[') && path_reference.end_with?(']')
192
+
193
+ path_reference[1...-1].split('][')
194
+ end
195
+
196
+ # given a Hash and an array of path fragments, returns the value at the path
197
+ # @param source [Hash{String=>Object}]
198
+ # @param path [Array{String}]
199
+ # @return [Object]
200
+ def extract_value(source, path)
201
+ path.reduce(source) do |memo, old_key_fragment|
202
+ break unless memo.include?(old_key_fragment)
203
+ memo[old_key_fragment]
204
+ end
205
+ end
206
+
207
+ # Given a "hits" object from an OpenSearch response, return the total number of hits in
208
+ # the result set.
209
+ # @param hits [Hash{String=>Object}]
210
+ # @return [Integer]
211
+ def extract_total_from_hits(hits)
212
+ total = hits['total']
213
+
214
+ # OpenSearch 7.x produces an object containing `value` and `relation` in order
215
+ # to enable unambiguous reporting when the total is only a lower bound; if we get
216
+ # an object back, return its `value`.
217
+ return total['value'] if total.kind_of?(Hash)
218
+
219
+ total
220
+ end
221
+
222
+ def hosts_default?(hosts)
223
+ # NOTE: would be nice if pipeline allowed us a clean way to detect a config default :
224
+ hosts.is_a?(Array) && hosts.size == 1 && hosts.first.equal?(DEFAULT_HOST)
225
+ end
226
+
227
+ def validate_authentication
228
+ authn_options = 0
229
+ authn_options += 1 if @cloud_auth
230
+ authn_options += 1 if (@api_key && @api_key.value)
231
+ authn_options += 1 if (@user || (@password && @password.value))
232
+
233
+ if authn_options > 1
234
+ raise LogStash::ConfigurationError, 'Multiple authentication options are specified, please only use one of user/password, cloud_auth or api_key'
235
+ end
236
+
237
+ if @api_key && @api_key.value && @ssl != true
238
+ raise(LogStash::ConfigurationError, "Using api_key authentication requires SSL/TLS secured communication using the `ssl => true` option")
239
+ end
240
+ end
241
+
242
+ def setup_hosts
243
+ @hosts = Array(@hosts).map { |host| host.to_s } # potential SafeURI#to_s
244
+ if @ssl
245
+ @hosts.map do |h|
246
+ host, port = h.split(":")
247
+ { :host => host, :scheme => 'https', :port => port }
248
+ end
249
+ else
250
+ @hosts
251
+ end
252
+ end
253
+
254
+ def setup_ssl
255
+ return { :ssl => true, :ca_file => @ca_file } if @ssl && @ca_file
256
+ return { :ssl => true, :verify => false } if @ssl # Setting verify as false if ca_file is not provided
257
+ end
258
+
259
+ def setup_basic_auth(user, password)
260
+ return {} unless user && password && password.value
261
+
262
+ token = ::Base64.strict_encode64("#{user}:#{password.value}")
263
+ { 'Authorization' => "Basic #{token}" }
264
+ end
265
+
266
+ def prepare_user_agent
267
+ os_name = java.lang.System.getProperty('os.name')
268
+ os_version = java.lang.System.getProperty('os.version')
269
+ os_arch = java.lang.System.getProperty('os.arch')
270
+ jvm_vendor = java.lang.System.getProperty('java.vendor')
271
+ jvm_version = java.lang.System.getProperty('java.version')
272
+
273
+ plugin_version = Gem.loaded_specs["logstash-filter-opensearch-manticore"].version
274
+ # example: logstash/7.14.1 (OS=Linux-5.4.0-84-generic-amd64; JVM=AdoptOpenJDK-11.0.11) logstash-input-opensearch/4.10.0
275
+ "logstash/#{LOGSTASH_VERSION} (OS=#{os_name}-#{os_version}-#{os_arch}; JVM=#{jvm_vendor}-#{jvm_version}) logstash-#{@plugin_type}-#{config_name}/#{plugin_version}"
276
+ end
277
+
278
+ def test_connection!
279
+ get_client.client.ping
280
+ end
281
+ end #class LogStash::Filters::OpenSearch
@@ -0,0 +1,29 @@
1
+ Gem::Specification.new do |s|
2
+
3
+ s.name = 'logstash-filter-opensearch-manticore'
4
+ s.version = '0.1.0'
5
+ s.licenses = ['Apache License (2.0)']
6
+ s.summary = "Copies fields from previous log events in OpenSearch to current events "
7
+ s.description = "This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline using $LS_HOME/bin/logstash-plugin install gemname. This gem is not a stand-alone program"
8
+ s.authors = ["Anton Klyba"]
9
+ s.email = 'anarhyst266@gmail.com'
10
+ s.homepage = "https://github.com/Anarhyst266/logstash-filter-opensearch-manticore"
11
+ s.require_paths = ["lib"]
12
+
13
+ # Files
14
+ s.files = Dir["lib/**/*","spec/**/*","*.gemspec","*.md","CONTRIBUTORS","Gemfile","LICENSE","NOTICE.TXT", "vendor/jar-dependencies/**/*.jar", "vendor/jar-dependencies/**/*.rb", "VERSION", "docs/**/*"]
15
+
16
+ # Tests
17
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
18
+
19
+ # Special flag to let us know this is actually a logstash plugin
20
+ s.metadata = { "logstash_plugin" => "true", "logstash_group" => "filter" }
21
+
22
+ # Gem dependencies
23
+ s.add_runtime_dependency "logstash-core-plugin-api", ">= 1.60", "<= 2.99"
24
+ s.add_runtime_dependency 'opensearch-ruby'
25
+ s.add_runtime_dependency 'manticore', "~> 0.6"
26
+
27
+ s.add_development_dependency 'logstash-devutils'
28
+ end
29
+
@@ -0,0 +1,70 @@
1
+ {
2
+ "took": 49,
3
+ "timed_out": false,
4
+ "_shards": {
5
+ "total": 155,
6
+ "successful": 155,
7
+ "failed": 0
8
+ },
9
+ "hits": {
10
+ "total": {
11
+ "value": 13476,
12
+ "relation": "eq"
13
+ },
14
+ "max_score": 1,
15
+ "hits": [{
16
+ "_index": "logstash-2014.08.26",
17
+ "_type": "logs",
18
+ "_id": "AVVY76L_AW7v0kX8KXo4",
19
+ "_score": 1,
20
+ "_source": {
21
+ "request": "/doc/index.html?org/opensearch/action/search/SearchResponse.html",
22
+ "agent": "\"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\"",
23
+ "geoip": {
24
+ "timezone": "America/Los_Angeles",
25
+ "ip": "66.249.73.185",
26
+ "latitude": 37.386,
27
+ "continent_code": "NA",
28
+ "city_name": "Mountain View",
29
+ "country_code2": "US",
30
+ "country_name": "United States",
31
+ "dma_code": 807,
32
+ "country_code3": "US",
33
+ "region_name": "California",
34
+ "location": [-122.0838,
35
+ 37.386
36
+ ],
37
+ "postal_code": "94035",
38
+ "longitude": -122.0838,
39
+ "region_code": "CA"
40
+ },
41
+ "auth": "-",
42
+ "ident": "-",
43
+ "verb": "GET",
44
+ "useragent": {
45
+ "os": "Other",
46
+ "major": "2",
47
+ "minor": "1",
48
+ "name": "Googlebot",
49
+ "os_name": "Other",
50
+ "device": "Spider"
51
+ },
52
+ "message": "66.249.73.185 - - [26/Aug/2014:21:22:13 +0000] \"GET /doc/index.html?org/opensearch/action/search/SearchResponse.html HTTP/1.1\" 404 294 \"-\" \"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\"",
53
+ "referrer": "\"-\"",
54
+ "@timestamp": "2014-08-26T21:22:13.000Z",
55
+ "response": 404,
56
+ "bytes": 294,
57
+ "clientip": "66.249.73.185",
58
+ "@version": "1",
59
+ "host": "skywalker",
60
+ "httpversion": "1.1",
61
+ "timestamp": "26/Aug/2014:21:22:13 +0000"
62
+ }
63
+ }]
64
+ },
65
+ "aggregations": {
66
+ "bytes_avg": {
67
+ "value": 294
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "query": {
3
+ "query_string": {
4
+ "query": "response: 404"
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "query": {
3
+ "terms": {
4
+ "lock": [ "잠금", "uzávěr" ]
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "took": 16,
3
+ "timed_out": false,
4
+ "_shards": {
5
+ "total": 155,
6
+ "successful": 100,
7
+ "failed": 55,
8
+ "failures": [
9
+ {
10
+ "shard": 0,
11
+ "index": "logstash-2014.08.26",
12
+ "node": "YI1MT0H-Q469pFgAVTXI2g",
13
+ "reason": {
14
+ "type": "search_parse_exception",
15
+ "reason": "No mapping found for [@timestamp] in order to sort on"
16
+ }
17
+ }
18
+ ]
19
+ },
20
+ "hits": {
21
+ "total": 0,
22
+ "max_score": null,
23
+ "hits": []
24
+ }
25
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "took": 49,
3
+ "timed_out": false,
4
+ "_shards": {
5
+ "total": 155,
6
+ "successful": 155,
7
+ "failed": 0
8
+ },
9
+ "hits": {
10
+ "total": 13476,
11
+ "max_score": 1,
12
+ "hits": []
13
+ },
14
+ "aggregations": {
15
+ "bytes_avg": {
16
+ "value": 294
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "took": 49,
3
+ "timed_out": false,
4
+ "_shards": {
5
+ "total": 155,
6
+ "successful": 155,
7
+ "failed": 0
8
+ },
9
+ "hits": {
10
+ "total": 13476,
11
+ "max_score": 1,
12
+ "hits": [{
13
+ "_index": "logstash-2014.08.26",
14
+ "_type": "logs",
15
+ "_id": "AVVY76L_AW7v0kX8KXo4",
16
+ "_score": 1,
17
+ "_source": {
18
+ "request": "/doc/index.html?org/opensearch/action/search/SearchResponse.html",
19
+ "agent": "\"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\"",
20
+ "geoip": {
21
+ "timezone": "America/Los_Angeles",
22
+ "ip": "66.249.73.185",
23
+ "latitude": 37.386,
24
+ "continent_code": "NA",
25
+ "city_name": "Mountain View",
26
+ "country_code2": "US",
27
+ "country_name": "United States",
28
+ "dma_code": 807,
29
+ "country_code3": "US",
30
+ "region_name": "California",
31
+ "location": [-122.0838,
32
+ 37.386
33
+ ],
34
+ "postal_code": "94035",
35
+ "longitude": -122.0838,
36
+ "region_code": "CA"
37
+ },
38
+ "auth": "-",
39
+ "ident": "-",
40
+ "verb": "GET",
41
+ "useragent": {
42
+ "os": "Other",
43
+ "major": "2",
44
+ "minor": "1",
45
+ "name": "Googlebot",
46
+ "os_name": "Other",
47
+ "device": "Spider"
48
+ },
49
+ "message": "66.249.73.185 - - [26/Aug/2014:21:22:13 +0000] \"GET /doc/index.html?org/opensearch/action/search/SearchResponse.html HTTP/1.1\" 404 294 \"-\" \"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\"",
50
+ "referrer": "\"-\"",
51
+ "@timestamp": "2014-08-26T21:22:13.000Z",
52
+ "response": 404,
53
+ "bytes": 294,
54
+ "clientip": "66.249.73.185",
55
+ "@version": "1",
56
+ "host": "skywalker",
57
+ "httpversion": "1.1",
58
+ "timestamp": "26/Aug/2014:21:22:13 +0000"
59
+ }
60
+ }]
61
+ },
62
+ "aggregations": {
63
+ "bytes_avg": {
64
+ "value": 294
65
+ }
66
+ }
67
+ }