logstash-output-loki 1.0.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9dd08c392ec88148a6f410e159169b3a19f877a25174fa3f43b2fdeb7fe16128
4
- data.tar.gz: 34aaf03576d8fbbb97aab02013113254734984ee1744c2ab4e8438bd51657569
3
+ metadata.gz: 5d3ae7289992fc6a6b98db3f5f1d929041656f2cd1e7e53915d48df9a8bf216b
4
+ data.tar.gz: 21e0b37676d7f6d0bca071bd2644a8ce96370e1c62efbf26becb3bce1de63f53
5
5
  SHA512:
6
- metadata.gz: 3520ab5618092976cfe771ad4f9ddea4aca3b96efe75d322c2f505e7fd6e642f3cee4775eb1ef0514c9db360a3c36b138e9f1426f6de9b625945cafdafb0b27d
7
- data.tar.gz: 6c7bb11d79fe0828e629bc27771a4f63584ea76af2ddb69eac950e5c7e3b115b62b674be28e8a828c97d8d3d851406748f0c83d49d9147080f0cc593ad1a076f
6
+ metadata.gz: 74e562693367ffcae0a7c639185ca75a984a156a7b7d8c7c61626b17ea01e20470901a2847230aff5f89d7b800f14cdc35ed82be93dd865af91d54548332dbf2
7
+ data.tar.gz: 9caf43738978ad056df2661460452c7059bd8a4c646f6e9949b3c7ee492b57ec1d0fa9320df02748f928fe4b002404ec2cfa75c0e9f4d78465606d38088dc8d7
data/Gemfile CHANGED
@@ -2,10 +2,13 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
4
 
5
- logstash_path = ENV["LOGSTASH_PATH"] || "logstash-libs"
6
- use_logstash_source = ENV["LOGSTASH_SOURCE"] && ENV["LOGSTASH_SOURCE"].to_s == "1"
5
+ logstash_path = ENV["LOGSTASH_PATH"] || "./logstash"
7
6
 
8
- if Dir.exist?(logstash_path) && use_logstash_source
7
+ if Dir.exist?(logstash_path)
9
8
  gem 'logstash-core', :path => "#{logstash_path}/logstash-core"
10
9
  gem 'logstash-core-plugin-api', :path => "#{logstash_path}/logstash-core-plugin-api"
10
+ else
11
+ raise 'missing logstash vendoring'
11
12
  end
13
+
14
+ gem "webmock", "~> 3.8"
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Loki Logstash Output Plugin
1
+ # Contributing to Loki Logstash Output Plugin
2
2
 
3
- Logstash plugin to send logstash aggregated logs to Loki.
3
+ For information about hwo to use this plugin see this [documentation](../../docs/clients/logstash/README.md).
4
4
 
5
5
  ## Install dependencies
6
6
 
@@ -41,11 +41,11 @@ git clone git@github.com:elastic/logstash.git
41
41
  cd logstash
42
42
  git checkout tags/v7.6.2
43
43
  export LOGSTASH_PATH=`pwd`
44
- export LOGSTASH_SOURCE="1"
45
- export GEM_PATH=$LOGSTASH_PATH/vendor/bundle/
46
- export GEM_HOME=$LOGSTASH_PATH/vendor/bundle/
44
+ export GEM_PATH=$LOGSTASH_PATH/vendor/bundle/jruby/2.5.0
45
+ export GEM_HOME=$LOGSTASH_PATH/vendor/bundle/jruby/2.5.0
46
+ ./gradlew assemble
47
47
  cd ..
48
- ruby -S bundle install --path
48
+ ruby -S bundle install
49
49
  ruby -S bundle exec rake vendor
50
50
  ```
51
51
 
@@ -55,7 +55,15 @@ ruby -S bundle exec rake vendor
55
55
 
56
56
  ### Test
57
57
 
58
- `bundle exec rspec`
58
+ `ruby -S bundle exec rspec`
59
+
60
+ Alternatively if you don't want to install JRuby. Enter inside logstash-loki container.
61
+
62
+ ```bash
63
+ docker build -t logstash-loki ./
64
+ docker run -v `pwd`/spec:/home/logstash/spec -it --rm --entrypoint /bin/sh logstash-loki
65
+ bundle exec rspec
66
+ ```
59
67
 
60
68
  ## Install plugin to local logstash
61
69
 
@@ -1,5 +1,7 @@
1
1
  # encoding: utf-8
2
2
  require "logstash/outputs/base"
3
+ require "logstash/outputs/loki/entry"
4
+ require "logstash/outputs/loki/batch"
3
5
  require "logstash/namespace"
4
6
  require 'net/http'
5
7
  require 'concurrent-edge'
@@ -8,9 +10,7 @@ require 'uri'
8
10
  require 'json'
9
11
 
10
12
  class LogStash::Outputs::Loki < LogStash::Outputs::Base
11
- require 'logstash/outputs/loki/batch'
12
- require 'logstash/outputs/loki/entry'
13
-
13
+ include Loki
14
14
  config_name "loki"
15
15
 
16
16
  ## 'A single instance of the Output will be shared among the pipeline worker threads'
@@ -39,12 +39,6 @@ class LogStash::Outputs::Loki < LogStash::Outputs::Base
39
39
  ## 'Interval in seconds to wait before pushing a batch of records to loki. Defaults to 1 second'
40
40
  config :batch_wait, :validate => :number, :default => 1, :required => false
41
41
 
42
- ## 'Array of label names to include in all logstreams'
43
- config :include_labels, :validate => :array, :default => [], :required => true
44
-
45
- ## 'Extra labels to add to all log streams'
46
- config :external_labels, :validate => :hash, :default => {}, :required => false
47
-
48
42
  ## 'Log line field to pick from logstash. Defaults to "message"'
49
43
  config :message_field, :validate => :string, :default => "message", :required => false
50
44
 
@@ -57,6 +51,7 @@ class LogStash::Outputs::Loki < LogStash::Outputs::Base
57
51
  ## 'Backoff configuration. Maximum number of retries to do'
58
52
  config :retries, :validate => :number, :default => 10, :required => false
59
53
 
54
+ attr_reader :batch
60
55
  public
61
56
  def register
62
57
  @uri = URI.parse(@url)
@@ -64,22 +59,16 @@ class LogStash::Outputs::Loki < LogStash::Outputs::Base
64
59
  raise LogStash::ConfigurationError, "url parameter must be valid HTTP, currently '#{@url}'"
65
60
  end
66
61
 
67
- if @include_labels.empty?
68
- raise LogStash::ConfigurationError, "include_labels should contain atleast one label, currently '#{@include_labels}'"
69
- end
70
-
71
62
  if @min_delay > @max_delay
72
63
  raise LogStash::ConfigurationError, "Min delay should be less than Max delay, currently 'Min delay is #{@min_delay} and Max delay is #{@max_delay}'"
73
64
  end
74
65
 
75
66
  @logger.info("Loki output plugin", :class => self.class.name)
76
67
 
77
- # intialize channels
68
+ # initialize channels
78
69
  @Channel = Concurrent::Channel
79
70
  @entries = @Channel.new
80
-
81
- # excluded message and timestamp from labels
82
- @exclude_labels = ["message", "@timestamp"]
71
+ @stop = @Channel.new
83
72
 
84
73
  # create nil batch object.
85
74
  @batch = nil
@@ -130,8 +119,8 @@ class LogStash::Outputs::Loki < LogStash::Outputs::Base
130
119
  end
131
120
 
132
121
  def run()
133
- min_wait_checkfrequency = 1/1000 #1 millisecond
134
- max_wait_checkfrequency = @batch_wait
122
+ min_wait_checkfrequency = 1/100 #1 millisecond
123
+ max_wait_checkfrequency = @batch_wait / 10
135
124
  if max_wait_checkfrequency < min_wait_checkfrequency
136
125
  max_wait_checkfrequency = min_wait_checkfrequency
137
126
  end
@@ -139,30 +128,21 @@ class LogStash::Outputs::Loki < LogStash::Outputs::Base
139
128
  @max_wait_check = Concurrent::Channel.tick(max_wait_checkfrequency)
140
129
  loop do
141
130
  Concurrent::Channel.select do |s|
131
+ s.take(@stop) {
132
+ return
133
+ }
142
134
  s.take(@entries) { |e|
143
- if @batch.nil?
144
- @batch = Batch.new(e)
145
- next
146
- end
147
-
148
- line = e.entry['line']
149
- if @batch.size_bytes_after(line) > @batch_size
150
- @logger.debug("Max batch_size is reached. Sending batch to loki")
151
- send(@tenant_id, @batch)
152
- @batch = Batch.new(e)
153
- next
154
- end
155
- @batch.add(e)
135
+ if !add_entry_to_batch(e)
136
+ @logger.debug("Max batch_size is reached. Sending batch to loki")
137
+ send(@batch)
138
+ @batch = Batch.new(e)
139
+ end
156
140
  }
157
141
  s.take(@max_wait_check) {
158
142
  # Send batch if max wait time has been reached
159
- if !@batch.nil?
160
- if @batch.age() < @batch_wait
161
- next
162
- end
163
-
143
+ if is_batch_expired
164
144
  @logger.debug("Max batch_wait time is reached. Sending batch to loki")
165
- send(@tenant_id, @batch)
145
+ send(@batch)
166
146
  @batch = nil
167
147
  end
168
148
  }
@@ -170,85 +150,62 @@ class LogStash::Outputs::Loki < LogStash::Outputs::Base
170
150
  end
171
151
  end
172
152
 
173
- ## Receives logstash events
174
- public
175
- def receive(event)
176
- labels = {}
177
- event_hash = event.to_hash
178
- lbls = handle_labels(event_hash, labels, "")
153
+ # add an entry to the current batch return false if the batch is full
154
+ # and the entry can't be added.
155
+ def add_entry_to_batch(e)
156
+ line = e.entry['line']
157
+ # we don't want to send empty lines.
158
+ return true if line.to_s.strip.empty?
179
159
 
180
- data_labels, entry_hash = build_entry(lbls, event)
181
- @entries << Entry.new(data_labels, entry_hash)
160
+ if @batch.nil?
161
+ @batch = Batch.new(e)
162
+ return true
163
+ end
182
164
 
165
+ if @batch.size_bytes_after(line) > @batch_size
166
+ return false
167
+ end
168
+ @batch.add(e)
169
+ return true
183
170
  end
184
171
 
185
- def close
186
- @logger.info("Closing loki output plugin. Flushing all pending batches")
187
- send(@tenant_id, @batch) if !@batch.nil?
188
- @entries.close
189
- @max_wait_check.close if !@max_wait_check.nil?
172
+ def is_batch_expired
173
+ return !@batch.nil? && @batch.age() >= @batch_wait
190
174
  end
191
175
 
192
- def build_entry(lbls, event)
193
- labels = lbls.merge(@external_labels)
194
- entry_hash = {
195
- "ts" => event.get("@timestamp").to_i * (10**9),
196
- "line" => event.get(@message_field).to_s
197
- }
198
- return labels, entry_hash
176
+ ## Receives logstash events
177
+ public
178
+ def receive(event)
179
+ @entries << Entry.new(event, @message_field)
199
180
  end
200
181
 
201
- def handle_labels(event_hash, labels, parent_key)
202
- event_hash.each{ |key,value|
203
- if !@exclude_labels.include?(key)
204
- if value.is_a?(Hash)
205
- if parent_key != ""
206
- handle_labels(value, labels, parent_key + "_" + key)
207
- else
208
- handle_labels(value, labels, key)
209
- end
210
- else
211
- if parent_key != ""
212
- labels[parent_key + "_" + key] = value.to_s
213
- else
214
- labels[key] = value.to_s
215
- end
216
- end
217
- end
218
- }
219
- return extract_labels(labels)
220
- end
182
+ def close
183
+ @entries.close
184
+ @max_wait_check.close if !@max_wait_check.nil?
185
+ @stop << true # stop will block until it's accepted by the worker.
221
186
 
222
- def extract_labels(extracted_labels)
223
- labels = {}
224
- extracted_labels.each { |key, value|
225
- if @include_labels.include?(key)
226
- key = key.gsub("@", '')
227
- labels[key] = value
228
- end
229
- }
230
- return labels
187
+ # if by any chance we still have a forming batch, we need to send it.
188
+ send(@batch) if !@batch.nil?
189
+ @batch = nil
231
190
  end
232
191
 
233
- def send(tenant_id, batch)
234
- payload = build_payload(batch)
235
- res = loki_http_request(tenant_id, payload, @min_delay, @max_delay, @retries)
236
-
192
+ def send(batch)
193
+ payload = batch.to_json
194
+ res = loki_http_request(payload)
237
195
  if res.is_a?(Net::HTTPSuccess)
238
196
  @logger.debug("Successfully pushed data to loki")
239
- return
240
197
  else
241
- @logger.error("failed to write post to ", :uri => @uri, :code => res.code, :body => res.body, :message => res.message) if !res.nil?
242
- @logger.debug("Payload object ", :payload => payload)
198
+ @logger.debug("failed payload", :payload => payload)
243
199
  end
244
200
  end
245
201
 
246
- def loki_http_request(tenant_id, payload, min_delay, max_delay, retries)
202
+ def loki_http_request(payload)
247
203
  req = Net::HTTP::Post.new(
248
204
  @uri.request_uri
249
205
  )
250
206
  req.add_field('Content-Type', 'application/json')
251
- req.add_field('X-Scope-OrgID', tenant_id) if tenant_id
207
+ req.add_field('X-Scope-OrgID', @tenant_id) if @tenant_id
208
+ req['User-Agent']= 'loki-logstash'
252
209
  req.basic_auth(@username, @password) if @username
253
210
  req.body = payload
254
211
 
@@ -256,53 +213,28 @@ class LogStash::Outputs::Loki < LogStash::Outputs::Base
256
213
 
257
214
  @logger.debug("sending #{req.body.length} bytes to loki")
258
215
  retry_count = 0
259
- delay = min_delay
216
+ delay = @min_delay
260
217
  begin
261
- res = Net::HTTP.start(@uri.host, @uri.port, **opts) { |http| http.request(req) }
262
- rescue Net::HTTPTooManyRequests, Net::HTTPServerError, Errno::ECONNREFUSED => e
263
- unless retry_count < retries
264
- @logger.error("Error while sending data to loki. Tried #{retry_count} times\n. :error => #{e}")
265
- return res
266
- end
267
-
218
+ res = Net::HTTP.start(@uri.host, @uri.port, **opts) { |http|
219
+ http.request(req)
220
+ }
221
+ return res if !res.nil? && res.code.to_i != 429 && res.code.to_i.div(100) != 5
222
+ raise StandardError.new res
223
+ rescue StandardError => e
268
224
  retry_count += 1
269
- @logger.warn("Trying to send again. Attempt number: #{retry_count}. Retrying in #{delay}s")
270
- sleep delay
271
-
272
- if (delay * 2 - delay) > max_delay
273
- delay = delay
225
+ @logger.warn("Failed to send batch attempt: #{retry_count}/#{@retries}", :error_inspect => e.inspect, :error => e)
226
+ if retry_count < @retries
227
+ sleep delay
228
+ if (delay * 2 - delay) > @max_delay
229
+ delay = delay
230
+ else
231
+ delay = delay * 2
232
+ end
233
+ retry
274
234
  else
275
- delay = delay * 2
235
+ @logger.error("Failed to send batch", :error_inspect => e.inspect, :error => e)
236
+ return res
276
237
  end
277
-
278
- retry
279
- rescue StandardError => e
280
- @logger.error("Error while connecting to loki server ", :error_inspect => e.inspect, :error => e)
281
- return res
282
238
  end
283
- return res
284
- end
285
-
286
- def build_payload(batch)
287
- payload = {}
288
- payload['streams'] = []
289
- batch.streams.each { |labels, stream|
290
- stream_obj = get_stream_obj(stream)
291
- payload['streams'].push(stream_obj)
292
- }
293
- return payload.to_json
294
- end
295
-
296
- def get_stream_obj(stream)
297
- stream_obj = {}
298
- stream_obj['stream'] = stream['labels']
299
- stream_obj['values'] = []
300
- values = []
301
- stream['entries'].each { |entry|
302
- values.push(entry['ts'].to_s)
303
- values.push(entry['line'])
304
- }
305
- stream_obj['values'].push(values)
306
- return stream_obj
307
239
  end
308
240
  end
@@ -1,47 +1,63 @@
1
1
  require 'time'
2
2
 
3
- module LogStash
4
- module Outputs
5
- class Loki
6
- class Batch
7
- attr_reader :streams
8
- def initialize(e)
9
- @bytes = 0
10
- @createdAt = Time.now
11
- @streams = {}
12
- add(e)
13
- end
14
-
15
- def size_bytes()
16
- return @bytes
17
- end
18
-
19
- def add(e)
20
- @bytes = @bytes + e.entry['line'].length
21
-
22
- # Append the entry to an already existing stream (if any)
23
- labels = e.labels.to_s
24
- if @streams.has_key?(labels)
25
- stream = @streams[labels]
26
- stream['entries'] = stream['entries'] + e.entry
27
- return
28
- else
29
- # Add the entry as a new stream
30
- @streams[labels] = {
31
- "labels" => e.labels,
32
- "entries" => [e.entry],
33
- }
34
- end
35
- end
36
-
37
- def size_bytes_after(line)
38
- return @bytes + line.length
39
- end
40
-
41
- def age()
42
- return Time.now - @createdAt
43
- end
44
- end
45
- end
46
- end
47
- end
3
+ module Loki
4
+ class Batch
5
+ attr_reader :streams
6
+ def initialize(e)
7
+ @bytes = 0
8
+ @createdAt = Time.now
9
+ @streams = {}
10
+ add(e)
11
+ end
12
+
13
+ def size_bytes
14
+ return @bytes
15
+ end
16
+
17
+ def add(e)
18
+ @bytes = @bytes + e.entry['line'].length
19
+
20
+ # Append the entry to an already existing stream (if any)
21
+ labels = e.labels.sort.to_h
22
+ labelkey = labels.to_s
23
+ if @streams.has_key?(labelkey)
24
+ stream = @streams[labelkey]
25
+ stream['entries'].append(e.entry)
26
+ return
27
+ else
28
+ # Add the entry as a new stream
29
+ @streams[labelkey] = {
30
+ "labels" => labels,
31
+ "entries" => [e.entry],
32
+ }
33
+ end
34
+ end
35
+
36
+ def size_bytes_after(line)
37
+ return @bytes + line.length
38
+ end
39
+
40
+ def age()
41
+ return Time.now - @createdAt
42
+ end
43
+
44
+ def to_json
45
+ streams = []
46
+ @streams.each { |_ , stream|
47
+ streams.append(build_stream(stream))
48
+ }
49
+ return {"streams"=>streams}.to_json
50
+ end
51
+
52
+ def build_stream(stream)
53
+ values = []
54
+ stream['entries'].each { |entry|
55
+ values.append([entry['ts'].to_s, entry['line']])
56
+ }
57
+ return {
58
+ 'stream'=>stream['labels'],
59
+ 'values' => values
60
+ }
61
+ end
62
+ end
63
+ end
@@ -1,13 +1,25 @@
1
- module LogStash
2
- module Outputs
3
- class Loki
4
- class Entry
5
- attr_reader :labels, :entry
6
- def initialize(labels, entry)
7
- @labels = labels
8
- @entry = entry
9
- end
10
- end
11
- end
12
- end
13
- end
1
+ module Loki
2
+ def to_ns(s)
3
+ (s.to_f * (10**9)).to_i
4
+ end
5
+ class Entry
6
+ include Loki
7
+ attr_reader :labels, :entry
8
+ def initialize(event,message_field)
9
+ @entry = {
10
+ "ts" => to_ns(event.get("@timestamp")),
11
+ "line" => event.get(message_field).to_s
12
+ }
13
+ event = event.clone()
14
+ event.remove(message_field)
15
+ event.remove("@timestamp")
16
+
17
+ @labels = {}
18
+ event.to_hash.each { |key,value|
19
+ next if key.start_with?('@')
20
+ next if value.is_a?(Hash)
21
+ @labels[key] = value.to_s
22
+ }
23
+ end
24
+ end
25
+ end
@@ -1,8 +1,8 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'logstash-output-loki'
3
- s.version = '1.0.0'
4
- s.authors = ['Aditya C S']
5
- s.email = ['aditya.gnu@gmail.com']
3
+ s.version = '1.0.1'
4
+ s.authors = ['Aditya C S','Cyril Tovena']
5
+ s.email = ['aditya.gnu@gmail.com','cyril.tovena@grafana.com']
6
6
 
7
7
  s.summary = 'Output plugin to ship logs to a Grafana Loki server'
8
8
  s.description = 'Output plugin to ship logs to a Grafana Loki server'
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
11
11
  s.require_paths = ["lib"]
12
12
 
13
13
  # Files
14
- s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
14
+ s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile']
15
15
  # Tests
16
16
  s.test_files = s.files.grep(%r{^(test|spec|features)/})
17
17
 
@@ -0,0 +1,59 @@
1
+ # encoding: utf-8
2
+ require "logstash/devutils/rspec/spec_helper"
3
+ require "logstash/outputs/loki"
4
+ require "logstash/codecs/plain"
5
+ require "logstash/event"
6
+ require "net/http"
7
+ include Loki
8
+
9
+ describe Loki::Entry do
10
+ context 'test entry generation' do
11
+ let (:event) {
12
+ LogStash::Event.new(
13
+ {
14
+ 'message' => 'hello',
15
+ '@metadata' => {'foo'=>'bar'},
16
+ '@version' => '1',
17
+ 'foo' => 5,
18
+ 'agent' => 'filebeat',
19
+ 'log' => {
20
+ 'file' =>
21
+ {'@path' => '/path/to/file.log'},
22
+ },
23
+ 'host' => '172.0.0.1',
24
+ '@timestamp' => Time.now
25
+ }
26
+ )
27
+ }
28
+
29
+ it 'labels extracted should not contains object and metadata or timestamp' do
30
+ entry = Entry.new(event,"message")
31
+ expect(entry.labels).to eql({ 'agent' => 'filebeat', 'host' => '172.0.0.1', 'foo'=>'5'})
32
+ expect(entry.entry['ts']).to eql to_ns(event.get("@timestamp"))
33
+ expect(entry.entry['line']).to eql 'hello'
34
+ end
35
+ end
36
+
37
+ context 'test batch generation with label order' do
38
+ let (:entries) {[
39
+ Entry.new(LogStash::Event.new({"message"=>"foobuzz","buzz"=>"bar","cluster"=>"us-central1","@timestamp"=>Time.at(1)}),"message"),
40
+ Entry.new(LogStash::Event.new({"log"=>"foobar","bar"=>"bar","@timestamp"=>Time.at(2)}),"log"),
41
+ Entry.new(LogStash::Event.new({"cluster"=>"us-central1","message"=>"foobuzz","buzz"=>"bar","@timestamp"=>Time.at(3)}),"message"),
42
+
43
+ ]}
44
+ let (:expected) {
45
+ {"streams" => [
46
+ {"stream"=> {"buzz"=>"bar","cluster"=>"us-central1"}, "values" => [[to_ns(Time.at(1)).to_s,"foobuzz"],[to_ns(Time.at(3)).to_s,"foobuzz"]]},
47
+ {"stream"=> {"bar"=>"bar"}, "values"=>[[to_ns(Time.at(2)).to_s,"foobar"]]},
48
+ ] }
49
+ }
50
+
51
+ it 'to_json' do
52
+ @batch = Loki::Batch.new(entries.first)
53
+ entries.drop(1).each { |e| @batch.add(e)}
54
+ expect(JSON.parse(@batch.to_json)).to eql expected
55
+ end
56
+ end
57
+
58
+
59
+ end
@@ -4,9 +4,12 @@ require "logstash/outputs/loki"
4
4
  require "logstash/codecs/plain"
5
5
  require "logstash/event"
6
6
  require "net/http"
7
+ require 'webmock/rspec'
8
+ include Loki
7
9
 
8
10
  describe LogStash::Outputs::Loki do
9
- let (:simple_loki_config) {{'url' => 'http://localhost:3100', 'include_labels' => ["test_key", "other_key"], 'external_labels' => {"test" => "value"}}}
11
+
12
+ let (:simple_loki_config) { {'url' => 'http://localhost:3100'} }
10
13
 
11
14
  context 'when initializing' do
12
15
  it "should register" do
@@ -14,100 +17,231 @@ describe LogStash::Outputs::Loki do
14
17
  expect { loki.register }.to_not raise_error
15
18
  end
16
19
 
17
- it 'should populate loki config with default or intialized values' do
20
+ it 'should populate loki config with default or initialized values' do
18
21
  loki = LogStash::Outputs::Loki.new(simple_loki_config)
19
22
  expect(loki.url).to eql 'http://localhost:3100'
20
23
  expect(loki.tenant_id).to eql nil
21
24
  expect(loki.batch_size).to eql 102400
22
25
  expect(loki.batch_wait).to eql 1
23
- expect(loki.include_labels).to eql ["test_key", "other_key"]
24
- expect(loki.external_labels).to include("test" => "value")
25
26
  end
26
27
  end
27
28
 
28
- context 'test labels' do
29
- let (:simple_loki_config) {{'url' => 'http://localhost:3100', 'include_labels' => ["@version", 'log_file_@path', 'host']}}
30
- let (:event) { LogStash::Event.new({'message' => 'hello', '@version' => '1', 'agent' => 'filebeat', 'log' => {'file' => {'@path' => '/path/to/file.log'}}, 'host' => '172.0.0.1',
31
- '@timestamp' => LogStash::Timestamp.now}) }
32
- let(:loki) { LogStash::Plugin.lookup("output", "loki").new(simple_loki_config) }
29
+ context 'when adding en entry to the batch' do
30
+ let (:simple_loki_config) {{'url' => 'http://localhost:3100'}}
31
+ let (:entry) {Entry.new(LogStash::Event.new({"message"=>"foobuzz","buzz"=>"bar","cluster"=>"us-central1","@timestamp"=>Time.at(1)}),"message")}
32
+ let (:lbs) { {"buzz"=>"bar","cluster"=>"us-central1"}.sort.to_h}
33
33
 
34
- before do
35
- loki.register
36
- loki.close
37
- end
34
+ it 'should not add empty line' do
35
+ plugin = LogStash::Plugin.lookup("output", "loki").new(simple_loki_config)
36
+ emptyEntry = Entry.new(LogStash::Event.new({"message"=>"foobuzz","buzz"=>"bar","cluster"=>"us-central1","@timestamp"=>Time.at(1)}),"foo")
37
+ expect(plugin.add_entry_to_batch(emptyEntry)).to eql true
38
+ expect(plugin.batch).to eql nil
39
+ end
38
40
 
39
- it 'labels extracted should have only included labels' do
40
- labels = {}
41
- event_hash = event.to_hash
42
- expected_labels = {"version" => "1", "host" => "172.0.0.1", "log_file_path" => '/path/to/file.log'}
43
- expect(loki.handle_labels(event_hash, labels, "")).to eql expected_labels
44
- end
41
+ it 'should add entry' do
42
+ plugin = LogStash::Plugin.lookup("output", "loki").new(simple_loki_config)
43
+ expect(plugin.batch).to eql nil
44
+ expect(plugin.add_entry_to_batch(entry)).to eql true
45
+ expect(plugin.add_entry_to_batch(entry)).to eql true
46
+ expect(plugin.batch).not_to be_nil
47
+ expect(plugin.batch.streams.length).to eq 1
48
+ expect(plugin.batch.streams[lbs.to_s]['entries'].length).to eq 2
49
+ expect(plugin.batch.streams[lbs.to_s]['labels']).to eq lbs
50
+ expect(plugin.batch.size_bytes).to eq 14
51
+ end
52
+
53
+ it 'should not add if full' do
54
+ plugin = LogStash::Plugin.lookup("output", "loki").new(simple_loki_config.merge!({'batch_size'=>10}))
55
+ expect(plugin.batch).to eql nil
56
+ expect(plugin.add_entry_to_batch(entry)).to eql true # first entry is fine.
57
+ expect(plugin.batch).not_to be_nil
58
+ expect(plugin.batch.streams.length).to eq 1
59
+ expect(plugin.batch.streams[lbs.to_s]['entries'].length).to eq 1
60
+ expect(plugin.batch.streams[lbs.to_s]['labels']).to eq lbs
61
+ expect(plugin.batch.size_bytes).to eq 7
62
+ expect(plugin.add_entry_to_batch(entry)).to eql false # second entry goes over the limit.
63
+ expect(plugin.batch).not_to be_nil
64
+ expect(plugin.batch.streams.length).to eq 1
65
+ expect(plugin.batch.streams[lbs.to_s]['entries'].length).to eq 1
66
+ expect(plugin.batch.streams[lbs.to_s]['labels']).to eq lbs
67
+ expect(plugin.batch.size_bytes).to eq 7
68
+ end
45
69
  end
46
70
 
47
- context 'validate entries' do
48
- let(:timestamp) {LogStash::Timestamp.now}
49
- let (:simple_loki_config) {{'url' => 'http://localhost:3100', 'include_labels' => ["version", "host", "test"], 'external_labels' => {"test" => "value"}}}
50
- let (:event) { LogStash::Event.new({'message' => 'hello', '@version' => '1', 'agent' => 'filebeat', 'host' => '172.0.0.1',
51
- '@timestamp' => timestamp}) }
52
- let(:loki) { LogStash::Plugin.lookup("output", "loki").new(simple_loki_config) }
71
+ context 'batch expiration' do
72
+ let (:entry) {Entry.new(LogStash::Event.new({"message"=>"foobuzz","buzz"=>"bar","cluster"=>"us-central1","@timestamp"=>Time.at(1)}),"message")}
53
73
 
54
- before do
55
- loki.register
56
- loki.close
74
+ it 'should not expire if empty' do
75
+ loki = LogStash::Outputs::Loki.new(simple_loki_config.merge!({'batch_wait'=>0.5}))
76
+ sleep(1)
77
+ expect(loki.is_batch_expired).to be false
57
78
  end
58
-
59
- it 'validate expected entries are added to entries stream' do
60
- labels = {"version" => "1", "host" => "172.0.0.1"}
61
- expected_labels = {"version" => "1", "host" => "172.0.0.1", "test" => "value"}
62
- expected_entry_hash = {
63
- "ts" => timestamp.to_i * (10**9),
64
- "line" => "hello".to_s
65
- }
66
- expected_labels_and_entry_hash = [{"version" => "1", "host" => "172.0.0.1", "test" => "value"}, expected_entry_hash]
67
- expect(loki.build_entry(labels, event)).to eq(expected_labels_and_entry_hash)
79
+ it 'should not expire batch if not old' do
80
+ loki = LogStash::Outputs::Loki.new(simple_loki_config.merge!({'batch_wait'=>0.5}))
81
+ expect(loki.add_entry_to_batch(entry)).to eql true
82
+ expect(loki.is_batch_expired).to be false
83
+ end
84
+ it 'should expire if old' do
85
+ loki = LogStash::Outputs::Loki.new(simple_loki_config.merge!({'batch_wait'=>0.5}))
86
+ expect(loki.add_entry_to_batch(entry)).to eql true
87
+ sleep(1)
88
+ expect(loki.is_batch_expired).to be true
68
89
  end
69
90
  end
70
91
 
71
- context 'test http requests' do
72
- let (:simple_loki_config) {{'url' => 'http://localhost:3100', 'include_labels' => ["@version", "host", "test"],}}
73
- let (:event) { LogStash::Event.new({'message' => 'hello', '@version' => '1', 'host' => '172.0.0.1',
74
- '@timestamp' => LogStash::Timestamp.now}) }
75
- let(:loki) { LogStash::Plugin.lookup("output", "loki").new(simple_loki_config) }
92
+ context 'channel' do
93
+ let (:event) {LogStash::Event.new({"message"=>"foobuzz","buzz"=>"bar","cluster"=>"us-central1","@timestamp"=>Time.at(1)})}
76
94
 
77
- before do
95
+ it 'should send entry if batch size reached with no tenant' do
96
+ loki = LogStash::Outputs::Loki.new(simple_loki_config.merge!({'batch_wait'=>0.5,'batch_size'=>10}))
78
97
  loki.register
98
+ sent = Concurrent::Channel.new(capacity: 3)
99
+ allow(loki).to receive(:send) do |batch|
100
+ Thread.new do
101
+ sent << batch
102
+ end
103
+ end
104
+ loki.receive(event)
105
+ loki.receive(event)
79
106
  loki.close
107
+ ~sent
108
+ ~sent
80
109
  end
110
+ it 'should send entry while closing' do
111
+ loki = LogStash::Outputs::Loki.new(simple_loki_config.merge!({'batch_wait'=>10,'batch_size'=>10}))
112
+ loki.register
113
+ sent = Concurrent::Channel.new(capacity: 3)
114
+ allow(loki).to receive(:send) do | batch|
115
+ Thread.new do
116
+ sent << batch
117
+ end
118
+ end
119
+ loki.receive(event)
120
+ loki.close
121
+ ~sent
122
+ end
123
+ it 'should send entry when batch is expiring' do
124
+ loki = LogStash::Outputs::Loki.new(simple_loki_config.merge!({'batch_wait'=>0.5,'batch_size'=>10}))
125
+ loki.register
126
+ sent = Concurrent::Channel.new(capacity: 3)
127
+ allow(loki).to receive(:send) do | batch|
128
+ Thread.new do
129
+ sent << batch
130
+ end
131
+ end
132
+ loki.receive(event)
133
+ ~sent
134
+ expect(loki.batch).to be_nil
135
+ loki.close
136
+ end
137
+ end
81
138
 
82
- it 'test http requests and raise_error when requests are not successful' do
83
- labels = {}
84
- event_hash = event.to_hash
85
- lbls = loki.handle_labels(event_hash, labels, "")
86
- entry_hash = {
87
- "ts" => event.get("@timestamp").to_i * (10**9),
88
- "line" => event.get("message").to_s
89
- }
90
- e = LogStash::Outputs::Loki::Entry.new(lbls, entry_hash)
91
- batch = LogStash::Outputs::Loki::Batch.new(e)
92
- payload = loki.build_payload(batch)
93
-
94
- # response should be nil on connection error
95
- expect(loki.loki_http_request("fake", payload, 1, 2, 3)).to eql nil
96
-
97
- success = Net::HTTPSuccess.new(1.0, 200, 'OK')
98
- allow(loki).to receive(:loki_http_request) { success }
99
- allow(success).to receive(:payload).and_return('fake body')
100
- expect(loki.loki_http_request("fake", batch, 1, 300, 10).class).to eql Net::HTTPSuccess
139
+ context 'http requests' do
140
+ let (:entry) {Entry.new(LogStash::Event.new({"message"=>"foobuzz","buzz"=>"bar","cluster"=>"us-central1","@timestamp"=>Time.at(1)}),"message")}
101
141
 
102
- too_many_requests = Net::HTTPTooManyRequests.new(1.0, 429, 'OK')
103
- allow(loki).to receive(:loki_http_request) { too_many_requests }
104
- allow(too_many_requests).to receive(:payload).and_return('fake body')
105
- expect(loki.loki_http_request("fake", batch, 1, 300, 10).class).to eql Net::HTTPTooManyRequests
142
+ it 'should send credentials' do
143
+ conf = {
144
+ 'url'=>'http://localhost:3100/loki/api/v1/push',
145
+ 'username' => 'foo',
146
+ 'password' => 'bar',
147
+ 'tenant_id' => 't'
148
+ }
149
+ loki = LogStash::Outputs::Loki.new(conf)
150
+ loki.register
151
+ b = Batch.new(entry)
152
+ post = stub_request(:post, "http://localhost:3100/loki/api/v1/push").with(
153
+ basic_auth: ['foo', 'bar'],
154
+ body: b.to_json,
155
+ headers:{
156
+ 'Content-Type' => 'application/json' ,
157
+ 'User-Agent' => 'loki-logstash',
158
+ 'X-Scope-OrgID'=>'t',
159
+ 'Accept'=>'*/*',
160
+ 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
161
+ }
162
+ )
163
+ loki.send(b)
164
+ expect(post).to have_been_requested.times(1)
165
+ end
106
166
 
107
- server_error = Net::HTTPServerError.new(1.0, 429, 'OK')
108
- allow(loki).to receive(:loki_http_request) { server_error }
109
- allow(server_error).to receive(:payload).and_return('fake body')
110
- expect(loki.loki_http_request("fake", batch, 1, 300, 10).class).to eql Net::HTTPServerError
167
+ it 'should not send credentials' do
168
+ conf = {
169
+ 'url'=>'http://foo.com/loki/api/v1/push',
170
+ }
171
+ loki = LogStash::Outputs::Loki.new(conf)
172
+ loki.register
173
+ b = Batch.new(entry)
174
+ post = stub_request(:post, "http://foo.com/loki/api/v1/push").with(
175
+ body: b.to_json,
176
+ headers:{
177
+ 'Content-Type' => 'application/json' ,
178
+ 'User-Agent' => 'loki-logstash',
179
+ 'Accept'=>'*/*',
180
+ 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
181
+ }
182
+ )
183
+ loki.send(b)
184
+ expect(post).to have_been_requested.times(1)
185
+ end
186
+ it 'should retry 500' do
187
+ conf = {
188
+ 'url'=>'http://foo.com/loki/api/v1/push',
189
+ 'retries' => 3,
190
+ }
191
+ loki = LogStash::Outputs::Loki.new(conf)
192
+ loki.register
193
+ b = Batch.new(entry)
194
+ post = stub_request(:post, "http://foo.com/loki/api/v1/push").with(
195
+ body: b.to_json,
196
+ ).to_return(status: [500, "Internal Server Error"])
197
+ loki.send(b)
198
+ loki.close
199
+ expect(post).to have_been_requested.times(3)
200
+ end
201
+ it 'should retry 429' do
202
+ conf = {
203
+ 'url'=>'http://foo.com/loki/api/v1/push',
204
+ 'retries' => 2,
205
+ }
206
+ loki = LogStash::Outputs::Loki.new(conf)
207
+ loki.register
208
+ b = Batch.new(entry)
209
+ post = stub_request(:post, "http://foo.com/loki/api/v1/push").with(
210
+ body: b.to_json,
211
+ ).to_return(status: [429, "stop spamming"])
212
+ loki.send(b)
213
+ loki.close
214
+ expect(post).to have_been_requested.times(2)
215
+ end
216
+ it 'should not retry 400' do
217
+ conf = {
218
+ 'url'=>'http://foo.com/loki/api/v1/push',
219
+ 'retries' => 11,
220
+ }
221
+ loki = LogStash::Outputs::Loki.new(conf)
222
+ loki.register
223
+ b = Batch.new(entry)
224
+ post = stub_request(:post, "http://foo.com/loki/api/v1/push").with(
225
+ body: b.to_json,
226
+ ).to_return(status: [400, "bad request"])
227
+ loki.send(b)
228
+ loki.close
229
+ expect(post).to have_been_requested.times(1)
230
+ end
231
+ it 'should retry exception' do
232
+ conf = {
233
+ 'url'=>'http://foo.com/loki/api/v1/push',
234
+ 'retries' => 11,
235
+ }
236
+ loki = LogStash::Outputs::Loki.new(conf)
237
+ loki.register
238
+ b = Batch.new(entry)
239
+ post = stub_request(:post, "http://foo.com/loki/api/v1/push").with(
240
+ body: b.to_json,
241
+ ).to_raise("some error").then.to_return(status: [200, "fine !"])
242
+ loki.send(b)
243
+ loki.close
244
+ expect(post).to have_been_requested.times(2)
111
245
  end
112
246
  end
113
247
  end
metadata CHANGED
@@ -1,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logstash-output-loki
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aditya C S
8
+ - Cyril Tovena
8
9
  autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
- date: 2020-07-13 00:00:00.000000000 Z
12
+ date: 2020-07-16 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  requirement: !ruby/object:Gem::Requirement
@@ -75,6 +76,7 @@ dependencies:
75
76
  description: Output plugin to ship logs to a Grafana Loki server
76
77
  email:
77
78
  - aditya.gnu@gmail.com
79
+ - cyril.tovena@grafana.com
78
80
  executables: []
79
81
  extensions: []
80
82
  extra_rdoc_files: []
@@ -85,6 +87,7 @@ files:
85
87
  - lib/logstash/outputs/loki/batch.rb
86
88
  - lib/logstash/outputs/loki/entry.rb
87
89
  - logstash-output-loki.gemspec
90
+ - spec/outputs/loki/entry_spec.rb
88
91
  - spec/outputs/loki_spec.rb
89
92
  homepage: https://github.com/grafana/loki/
90
93
  licenses:
@@ -112,4 +115,5 @@ signing_key:
112
115
  specification_version: 4
113
116
  summary: Output plugin to ship logs to a Grafana Loki server
114
117
  test_files:
118
+ - spec/outputs/loki/entry_spec.rb
115
119
  - spec/outputs/loki_spec.rb