logtail 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +3 -0
- data/lib/logtail/config.rb +4 -0
- data/lib/logtail/event.rb +1 -1
- data/lib/logtail/events/controller_call.rb +1 -1
- data/lib/logtail/events/error.rb +1 -1
- data/lib/logtail/log_devices/http/request_attempt.rb +3 -2
- data/lib/logtail/log_devices/http.rb +374 -26
- data/lib/logtail/log_entry.rb +19 -7
- data/lib/logtail/logger.rb +32 -7
- data/lib/logtail/util/non_nil_hash_builder.rb +1 -1
- data/lib/logtail/util.rb +8 -0
- data/lib/logtail/version.rb +1 -1
- data/spec/logtail/config_spec.rb +16 -0
- data/spec/logtail/json_encoding_spec.rb +121 -0
- data/spec/logtail/log_devices/http_exit_spec.rb +142 -0
- data/spec/logtail/log_devices/http_fork_spec.rb +85 -0
- data/spec/logtail/log_devices/http_spec.rb +551 -1
- data/spec/logtail/log_entry_spec.rb +73 -0
- data/spec/logtail/logger_flush_all_spec.rb +82 -0
- data/spec/logtail/logger_spec.rb +77 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/processes.rb +91 -0
- metadata +7 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
require "spec_helper"
|
|
2
|
+
require "bigdecimal"
|
|
3
|
+
require "date"
|
|
4
|
+
require "set"
|
|
2
5
|
|
|
3
6
|
# Note: these tests access instance variables and private methods as a means of
|
|
4
7
|
# not muddying the public API. This object should expose a simple buffer like
|
|
@@ -22,7 +25,7 @@ describe Logtail::LogDevices::HTTP do
|
|
|
22
25
|
|
|
23
26
|
it "should buffer the messages" do
|
|
24
27
|
http.write("test log message")
|
|
25
|
-
expect(msg_queue.flush).to eq(["test log message"])
|
|
28
|
+
expect(msg_queue.flush.map(&:message)).to eq(["test log message"])
|
|
26
29
|
http.close
|
|
27
30
|
end
|
|
28
31
|
|
|
@@ -68,6 +71,49 @@ describe Logtail::LogDevices::HTTP do
|
|
|
68
71
|
expect(http).to receive(:flush).exactly(1).times
|
|
69
72
|
http.close
|
|
70
73
|
end
|
|
74
|
+
|
|
75
|
+
it "does nothing when called again, e.g. by the device's at_exit hook" do
|
|
76
|
+
http.close
|
|
77
|
+
expect(http).not_to receive(:flush)
|
|
78
|
+
http.close
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "doesn't let a line written after it raise when delivering it fails, whatever the error" do
|
|
82
|
+
http.close
|
|
83
|
+
|
|
84
|
+
# WebMock refuses to connect with an error that isn't a StandardError
|
|
85
|
+
expect { http.write(Logtail::LogEntry.new("INFO", Time.now, nil, "test log message", nil, nil)) }.not_to raise_error
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "stops waiting for the outlet thread once it has died" do
|
|
89
|
+
stub_request(:post, "https://in.logs.betterstack.com/")
|
|
90
|
+
http.write(Logtail::LogEntry.new("INFO", Time.now, nil, "test log message", nil, nil))
|
|
91
|
+
http.instance_variable_get(:@request_outlet_thread).kill.join
|
|
92
|
+
|
|
93
|
+
closing = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
94
|
+
http.close
|
|
95
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - closing).to be < 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "drops the lines written after it once Better Stack answered one of them with 429 or 5xx" do
|
|
99
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return({status: 503}, {status: 202})
|
|
100
|
+
http.close
|
|
101
|
+
|
|
102
|
+
2.times { http.write(Logtail::LogEntry.new("INFO", Time.now, nil, "test log message", nil, nil)) }
|
|
103
|
+
|
|
104
|
+
expect(stub).to have_been_requested.once
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "reports a line written after it that Better Stack rejects" do
|
|
108
|
+
# A status is reported once per process, so none has been reported yet.
|
|
109
|
+
stub_const("Logtail::LogDevices::HTTP::REPORTED_REJECTIONS", [])
|
|
110
|
+
stub_request(:post, "https://in.logs.betterstack.com/").to_return(status: [403, "Forbidden"])
|
|
111
|
+
http.close
|
|
112
|
+
expect(http).to receive(:warn).once.with("Logtail: Better Stack rejected 1 log line with HTTP 403 " \
|
|
113
|
+
"Forbidden - check your source token. Further rejections with this status won't be reported.")
|
|
114
|
+
|
|
115
|
+
http.write(Logtail::LogEntry.new("INFO", Time.now, nil, "test log message", nil, nil))
|
|
116
|
+
end
|
|
71
117
|
end
|
|
72
118
|
|
|
73
119
|
# Testing a private method because it helps break down our tests
|
|
@@ -84,6 +130,128 @@ describe Logtail::LogDevices::HTTP do
|
|
|
84
130
|
http.send(:flush)
|
|
85
131
|
http.close
|
|
86
132
|
end
|
|
133
|
+
|
|
134
|
+
it "delivers in the calling thread when no outlet thread runs" do
|
|
135
|
+
messages = []
|
|
136
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return do |request|
|
|
137
|
+
messages.concat(MessagePack.unpack(Zlib::Inflate.inflate(request.body)).map { |line| line["message"] })
|
|
138
|
+
{ status: 202 }
|
|
139
|
+
end
|
|
140
|
+
http = described_class.new("MYKEY", flush_continuously: false)
|
|
141
|
+
http.write(Logtail::LogEntry.new("INFO", time, nil, "test log message 1", nil, nil))
|
|
142
|
+
http.write(Logtail::LogEntry.new("INFO", time, nil, "test log message 2", nil, nil))
|
|
143
|
+
|
|
144
|
+
http.flush
|
|
145
|
+
|
|
146
|
+
expect(stub).to have_been_requested.once
|
|
147
|
+
expect(messages).to eq(["test log message 1", "test log message 2"])
|
|
148
|
+
http.close
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "doesn't raise when delivering in the calling thread fails, whatever the error" do
|
|
152
|
+
http = described_class.new("MYKEY", flush_continuously: false)
|
|
153
|
+
http.write(Logtail::LogEntry.new("INFO", time, nil, "test log message", nil, nil))
|
|
154
|
+
|
|
155
|
+
# WebMock refuses to connect with an error that isn't a StandardError
|
|
156
|
+
expect { http.flush }.not_to raise_error
|
|
157
|
+
http.close
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "doesn't warn about @last_resp when it can't connect while Ruby shuts down, with warnings on" do
|
|
161
|
+
# Ruby 2.7 and older warn about an instance variable that is read before it's set
|
|
162
|
+
result = run_ruby(<<-RUBY)
|
|
163
|
+
$VERBOSE = true
|
|
164
|
+
require "logtail"
|
|
165
|
+
# Like Net::HTTP before Ruby 4.0 while Ruby shuts down: it can't start the thread that
|
|
166
|
+
# times out connecting
|
|
167
|
+
Net::HTTP.prepend(Module.new { def start(*); raise ThreadError, "can't alloc thread"; end })
|
|
168
|
+
http = Logtail::LogDevices::HTTP.new("token", flush_continuously: false, ingesting_host: "127.0.0.1", ingesting_port: 1, ingesting_scheme: "http")
|
|
169
|
+
logger = Logtail::Logger.new(http)
|
|
170
|
+
logger.info("line")
|
|
171
|
+
logger.flush
|
|
172
|
+
RUBY
|
|
173
|
+
|
|
174
|
+
expect(result.status).to be_success, result.stderr
|
|
175
|
+
expect(result.stderr).not_to include("@last_resp not initialized")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "waits about 5 seconds at most for the outlet thread to deliver" do
|
|
179
|
+
allow_any_instance_of(Net::HTTP).to receive(:request) { sleep } # Better Stack never answers
|
|
180
|
+
http = described_class.new("MYKEY")
|
|
181
|
+
http.write(Logtail::LogEntry.new("INFO", time, nil, "test log message", nil, nil))
|
|
182
|
+
|
|
183
|
+
flushing = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
184
|
+
http.flush
|
|
185
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - flushing).to be_between(4, 7)
|
|
186
|
+
|
|
187
|
+
http.instance_variable_get(:@flush_thread).kill.join
|
|
188
|
+
http.instance_variable_get(:@request_outlet_thread).kill.join
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
context "when Better Stack answers the delivery in the calling thread with an error status" do
|
|
192
|
+
let(:http) { described_class.new("MYKEY", flush_continuously: false) }
|
|
193
|
+
|
|
194
|
+
# A status is reported once per process, so none has been reported yet in each example.
|
|
195
|
+
before { stub_const("Logtail::LogDevices::HTTP::REPORTED_REJECTIONS", []) }
|
|
196
|
+
|
|
197
|
+
it "drops batches rejected with 401 and warns only once" do
|
|
198
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return(status: [401, "Unauthorized"])
|
|
199
|
+
expect(http).to receive(:warn).once.with("Logtail: Better Stack rejected 2 log lines with HTTP 401 " \
|
|
200
|
+
"Unauthorized - check your source token. Further rejections with this status won't be reported.")
|
|
201
|
+
|
|
202
|
+
2.times do
|
|
203
|
+
2.times { |i| http.write(Logtail::LogEntry.new("INFO", time, nil, "line #{i}", nil, nil)) }
|
|
204
|
+
http.flush
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
expect(stub).to have_been_requested.twice
|
|
208
|
+
http.close
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "neither retries nor reports a batch answered with 408, 429 or 5xx" do
|
|
212
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").
|
|
213
|
+
to_return({status: 503}, {status: 429}, {status: [408, "Request Time-out"]})
|
|
214
|
+
expect(http).not_to receive(:warn)
|
|
215
|
+
|
|
216
|
+
3.times do
|
|
217
|
+
http.write(Logtail::LogEntry.new("INFO", time, nil, "test log message", nil, nil))
|
|
218
|
+
http.flush
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
expect(stub).to have_been_requested.times(3)
|
|
222
|
+
http.close
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "waits up to 5 seconds for a slow host when it delivers in the calling thread" do
|
|
227
|
+
# Takes 3 seconds to answer each request
|
|
228
|
+
slow_ingest = Class.new(LocalIngestServer) do
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def serve(socket)
|
|
232
|
+
def socket.write(*)
|
|
233
|
+
sleep 3
|
|
234
|
+
super
|
|
235
|
+
end
|
|
236
|
+
super
|
|
237
|
+
end
|
|
238
|
+
end.new
|
|
239
|
+
result = run_ruby(<<-RUBY)
|
|
240
|
+
require "logtail"
|
|
241
|
+
# A request per line, which flush delivers as no outlet thread runs
|
|
242
|
+
http = Logtail::LogDevices::HTTP.new("token", flush_continuously: false, batch_size: 1, #{slow_ingest.device_options})
|
|
243
|
+
logger = Logtail::Logger.new(http)
|
|
244
|
+
logger.info("first line")
|
|
245
|
+
logger.info("second line")
|
|
246
|
+
logger.flush
|
|
247
|
+
RUBY
|
|
248
|
+
|
|
249
|
+
# A request that times out would drop the one after it
|
|
250
|
+
expect(result.status).to be_success, result.stderr
|
|
251
|
+
expect(slow_ingest.messages).to contain_exactly("first line", "second line")
|
|
252
|
+
ensure
|
|
253
|
+
slow_ingest.stop if slow_ingest
|
|
254
|
+
end
|
|
87
255
|
end
|
|
88
256
|
|
|
89
257
|
# Testing a private method because it helps break down our tests
|
|
@@ -108,6 +276,139 @@ describe Logtail::LogDevices::HTTP do
|
|
|
108
276
|
end
|
|
109
277
|
end
|
|
110
278
|
|
|
279
|
+
# Testing a private method because it helps break down our tests
|
|
280
|
+
describe "#build_request" do
|
|
281
|
+
let(:http) { described_class.new("MYKEY", flush_continuously: false) }
|
|
282
|
+
let(:logger) { Logtail::Logger.new(http) }
|
|
283
|
+
|
|
284
|
+
# Flushes the buffer and decodes the request body, the way the API reads it.
|
|
285
|
+
def delivered_entries
|
|
286
|
+
http.send(:flush_async)
|
|
287
|
+
request = http.instance_variable_get(:@request_queue).deq.request
|
|
288
|
+
MessagePack.unpack(Zlib::Inflate.inflate(request.body))
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
a_proc = proc {}
|
|
292
|
+
an_object = Object.new
|
|
293
|
+
a_cyclic_hash = { name: "parent" }
|
|
294
|
+
a_cyclic_hash[:self] = a_cyclic_hash
|
|
295
|
+
a_cyclic_array = ["parent"]
|
|
296
|
+
a_cyclic_array << a_cyclic_array
|
|
297
|
+
# Like a Rack::Session::SessionId, whose public id is the cookie of a server-side session
|
|
298
|
+
a_session_id = Object.new
|
|
299
|
+
def a_session_id.private_id
|
|
300
|
+
"2::hashed-session-id"
|
|
301
|
+
end
|
|
302
|
+
def a_session_id.to_s
|
|
303
|
+
"session-cookie"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
"a Time" => [Time.utc(2026, 10, 1, 12, 0, 0, 123456), "2026-10-01T12:00:00.123456Z"],
|
|
308
|
+
"a Time with a UTC offset" => [Time.new(2026, 10, 1, 14, 0, 0, "+02:00"), "2026-10-01T12:00:00.000000Z"],
|
|
309
|
+
"a DateTime" => [DateTime.new(2026, 10, 1, 14, 0, 0, "+02:00"), "2026-10-01T12:00:00.000000Z"],
|
|
310
|
+
"a Date" => [Date.new(2026, 10, 1), "2026-10-01"],
|
|
311
|
+
"a BigDecimal" => [BigDecimal("19.99"), "19.99"],
|
|
312
|
+
"a Rational" => [Rational(1, 3), "1/3"],
|
|
313
|
+
"an Integer above the 64-bit range" => [2**64, "18446744073709551616"],
|
|
314
|
+
"an Integer below the 64-bit range" => [-2**63 - 1, "-9223372036854775809"],
|
|
315
|
+
"a Set" => [Set[1, 2], [1, 2]],
|
|
316
|
+
"a Struct" => [Struct.new(:id, :name).new(1, "Ann"), { "id" => 1, "name" => "Ann" }],
|
|
317
|
+
"an Exception" => [ArgumentError.new("boom"), { "class" => "ArgumentError", "message" => "boom" }],
|
|
318
|
+
"a Range" => [1..2, "1..2"],
|
|
319
|
+
"a Class" => [String, "String"],
|
|
320
|
+
"a Proc" => [a_proc, a_proc.to_s],
|
|
321
|
+
"an arbitrary object" => [an_object, an_object.to_s],
|
|
322
|
+
"an object with a private id" => [a_session_id, "2::hashed-session-id"],
|
|
323
|
+
"a Hash that contains itself" => [a_cyclic_hash, { "name" => "parent", "self" => "[circular]" }],
|
|
324
|
+
"an Array that contains itself" => [a_cyclic_array, ["parent", "[circular]"]],
|
|
325
|
+
}.each do |description, (value, expected)|
|
|
326
|
+
it "delivers the whole batch when a log line holds #{description}" do
|
|
327
|
+
logger.info("line before")
|
|
328
|
+
logger.info("line with the value", value: value)
|
|
329
|
+
logger.info("line after")
|
|
330
|
+
|
|
331
|
+
entries = delivered_entries
|
|
332
|
+
expect(entries.map { |entry| entry["message"] }).to eq(["line before", "line with the value", "line after"])
|
|
333
|
+
expect(entries[1]["value"]).to eq(expected)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "replaces a log line it still can't encode with one that says why, and delivers the rest" do
|
|
338
|
+
unencodable = Object.new
|
|
339
|
+
def unencodable.to_s
|
|
340
|
+
raise "to_s failed"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
logger.info("line before")
|
|
344
|
+
logger.warn("line with the value", value: unencodable)
|
|
345
|
+
logger.info("line after")
|
|
346
|
+
|
|
347
|
+
entries = delivered_entries
|
|
348
|
+
expect(entries.map { |entry| entry["message"] }).to eq([
|
|
349
|
+
"line before",
|
|
350
|
+
"Logtail could not encode this log line (RuntimeError: to_s failed): line with the value",
|
|
351
|
+
"line after",
|
|
352
|
+
])
|
|
353
|
+
expect(entries[1].keys).to contain_exactly("level", "dt", "message")
|
|
354
|
+
expect(entries[1]["level"]).to eq("warn")
|
|
355
|
+
expect(entries[1]["dt"]).to match(/\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z\z/)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
it "sends a log line that msgpack can encode as it is, without copying it" do
|
|
359
|
+
hash = { message: "caf\u00E9", count: 1, ratio: 0.5, flag: true, none: nil, level: :info, nested: { list: [1, "two"] } }
|
|
360
|
+
|
|
361
|
+
expect(http.send(:encodable_value, hash)).to be(hash)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "passes strings that aren't valid UTF-8 to force_utf8_encoding, also in arrays and keys" do
|
|
365
|
+
in_array = "in an array \xFF".b
|
|
366
|
+
key = "key \xFF".b
|
|
367
|
+
allow(http).to receive(:force_utf8_encoding).and_call_original
|
|
368
|
+
|
|
369
|
+
logger.info("line", items: [in_array], counts: { key => 1 })
|
|
370
|
+
delivered_entries
|
|
371
|
+
|
|
372
|
+
expect(http).to have_received(:force_utf8_encoding).with(in_array).at_least(:once)
|
|
373
|
+
expect(http).to have_received(:force_utf8_encoding).with(key).at_least(:once)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
it "keeps the order of the keys of a hash when it converts some of them" do
|
|
377
|
+
logger.info("line", value: { "a" => 1, Time.utc(2026, 10, 1) => 2, "c" => Date.new(2026, 10, 1) })
|
|
378
|
+
|
|
379
|
+
expect(delivered_entries[0]["value"].to_a).to eq([["a", 1], ["2026-10-01T00:00:00.000000Z", 2], ["c", "2026-10-01"]])
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it "leaves the logged values as they are" do
|
|
383
|
+
value = { at: Time.utc(2026, 10, 1), nested: { on: Date.new(2026, 10, 1), list: [Set[1], "\xFF".b] } }
|
|
384
|
+
original = Marshal.load(Marshal.dump(value))
|
|
385
|
+
|
|
386
|
+
logger.info("line", value: value)
|
|
387
|
+
delivered_entries
|
|
388
|
+
|
|
389
|
+
expect(value).to eq(original)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
it "delivers hashes and arrays nested 110 levels deep" do
|
|
393
|
+
value = "leaf"
|
|
394
|
+
110.times { |level| value = level.even? ? { "level #{level}" => value } : [value] }
|
|
395
|
+
|
|
396
|
+
logger.info("line", value: value)
|
|
397
|
+
|
|
398
|
+
expect(delivered_entries[0]["value"]).to eq(value)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
it "delivers strings written to the device as info lines" do
|
|
402
|
+
http.write("written to the device\n")
|
|
403
|
+
::Logger.new(http).warn("logged by a plain Ruby logger")
|
|
404
|
+
|
|
405
|
+
entries = delivered_entries
|
|
406
|
+
expect(entries.map { |entry| entry["level"] }).to eq(["info", "info"])
|
|
407
|
+
expect(entries[0]["message"]).to eq("written to the device")
|
|
408
|
+
expect(entries[1]["message"]).to end_with("WARN -- : logged by a plain Ruby logger")
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
111
412
|
# Testing a private method because it helps break down our tests
|
|
112
413
|
describe "#intervaled_flush" do
|
|
113
414
|
it "should start a intervaled flush thread and flush on an interval" do
|
|
@@ -119,6 +420,74 @@ describe Logtail::LogDevices::HTTP do
|
|
|
119
420
|
end
|
|
120
421
|
end
|
|
121
422
|
|
|
423
|
+
# Testing a private method because it helps break down our tests
|
|
424
|
+
describe "#force_utf8_encoding" do
|
|
425
|
+
let(:http) { described_class.new("MYKEY", flush_continuously: false) }
|
|
426
|
+
let(:logger) { Logtail::Logger.new(http) }
|
|
427
|
+
|
|
428
|
+
# Flushes the buffer and decodes the single entry in the request, the way the API reads it.
|
|
429
|
+
def delivered_entry
|
|
430
|
+
http.send(:flush_async)
|
|
431
|
+
request = http.instance_variable_get(:@request_queue).deq.request
|
|
432
|
+
MessagePack.unpack(Zlib::Inflate.inflate(request.body)).first
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
it "replaces invalid bytes in the message with U+FFFD" do
|
|
436
|
+
logger.info("invalid \xFF\xFE bytes")
|
|
437
|
+
expect(delivered_entry["message"]).to eq("invalid �� bytes")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
it "treats a binary message as UTF-8" do
|
|
441
|
+
logger.info("caf\xC3\xA9 \xFF".b)
|
|
442
|
+
expect(delivered_entry["message"]).to eq("café �")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
it "replaces invalid bytes in a nested field" do
|
|
446
|
+
logger.info("nested", user: { name: "An\xFFn" })
|
|
447
|
+
expect(delivered_entry["user"]).to eq("name" => "An�n")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
it "replaces invalid bytes in array elements" do
|
|
451
|
+
logger.info("array", items: ["valid", "\xFF\xFE".b, ["in\xFFner"]])
|
|
452
|
+
expect(delivered_entry["items"]).to eq(["valid", "��", ["in�ner"]])
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
it "replaces invalid bytes in hash keys" do
|
|
456
|
+
logger.info("keys", counts: { "k\xFFy".b => 1 })
|
|
457
|
+
expect(delivered_entry["counts"]).to eq("k�y" => 1)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it "replaces invalid bytes in the context" do
|
|
461
|
+
Logtail.with_context(request: { path: "/caf\xE9".b }) { logger.info("context") }
|
|
462
|
+
expect(delivered_entry["context"]["request"]).to eq("path" => "/caf�")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
it "converts strings in other encodings to UTF-8" do
|
|
466
|
+
logger.info("latin-1", city: "Montr\xE9al".dup.force_encoding("ISO-8859-1"))
|
|
467
|
+
expect(delivered_entry["city"]).to eq("Montréal")
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
it "sends valid UTF-8 for a string in an encoding Ruby can't convert" do
|
|
471
|
+
logger.info("windows-1258", word: "caf\xE9".dup.force_encoding("Windows-1258"))
|
|
472
|
+
word = delivered_entry["word"]
|
|
473
|
+
expect(word).to start_with("caf")
|
|
474
|
+
expect(word).to be_valid_encoding
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
it "sends a string that is valid UTF-8 already as it is, without a copy" do
|
|
478
|
+
utf8 = "café"
|
|
479
|
+
ascii = "ascii".encode("US-ASCII")
|
|
480
|
+
|
|
481
|
+
expect(http.send(:force_utf8_encoding, utf8)).to be(utf8)
|
|
482
|
+
expect(http.send(:force_utf8_encoding, ascii)).to be(ascii)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
it "treats a US-ASCII string with bytes above 127 as UTF-8" do
|
|
486
|
+
logger.info("us-ascii", word: "caf\xC3\xA9 \xFF".dup.force_encoding("US-ASCII"))
|
|
487
|
+
expect(delivered_entry["word"]).to eq("café �")
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
122
491
|
# Outlet
|
|
123
492
|
describe "#request_outlet" do
|
|
124
493
|
let(:time) { Time.utc(2016, 9, 1, 12, 0, 0) }
|
|
@@ -148,6 +517,187 @@ describe Logtail::LogDevices::HTTP do
|
|
|
148
517
|
|
|
149
518
|
http.close
|
|
150
519
|
end
|
|
520
|
+
|
|
521
|
+
context "when connecting or delivering fails" do
|
|
522
|
+
# Raised from the stubs below to leave the outlet's endless loop. It is not a
|
|
523
|
+
# StandardError, so the outlet's own `rescue => e` lets it through.
|
|
524
|
+
let(:stop_outlet) { Class.new(Exception) }
|
|
525
|
+
let(:http_device) { described_class.new("MYKEY", flush_continuously: false, requests_per_conn: 1) }
|
|
526
|
+
let(:request_queue) { http_device.instance_variable_get(:@request_queue) }
|
|
527
|
+
let(:waits) { [] }
|
|
528
|
+
|
|
529
|
+
before do
|
|
530
|
+
allow(http_device).to receive(:sleep) { |seconds| waits << seconds }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
it "waits before reconnecting, twice as long after every refused connection, up to 30 seconds" do
|
|
534
|
+
connection_attempts = 0
|
|
535
|
+
allow_any_instance_of(Net::HTTP).to receive(:start) do
|
|
536
|
+
connection_attempts += 1
|
|
537
|
+
raise stop_outlet if connection_attempts > 7
|
|
538
|
+
raise Errno::ECONNREFUSED
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
542
|
+
expect(waits).to eq([1, 2, 4, 8, 16, 30, 30])
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
it "waits the same way when a request fails on an open connection, and still drops it after 3 attempts" do
|
|
546
|
+
request_queue.enq(Logtail::LogDevices::HTTP::RequestAttempt.new(Net::HTTP::Post.new("/")))
|
|
547
|
+
request_attempts = 0
|
|
548
|
+
allow_any_instance_of(Net::HTTP).to receive(:request) do
|
|
549
|
+
request_attempts += 1
|
|
550
|
+
raise Errno::ECONNRESET
|
|
551
|
+
end
|
|
552
|
+
allow(http_device).to receive(:sleep) do |seconds|
|
|
553
|
+
waits << seconds
|
|
554
|
+
raise stop_outlet if waits.size == 3
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
558
|
+
expect(waits).to eq([1, 2, 4])
|
|
559
|
+
expect(request_attempts).to eq(3)
|
|
560
|
+
expect(request_queue.size).to eq(0)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
it "starts over at 1 second once a request is delivered" do
|
|
564
|
+
request_queue.enq(Logtail::LogDevices::HTTP::RequestAttempt.new(Net::HTTP::Post.new("/")))
|
|
565
|
+
connection = double("connection", request: double("response", code: "202"))
|
|
566
|
+
connections = [:refused, :refused, :delivers, :refused, :refused]
|
|
567
|
+
allow_any_instance_of(Net::HTTP).to receive(:start) do |_http, &block|
|
|
568
|
+
case connections.shift
|
|
569
|
+
when :refused then raise Errno::ECONNREFUSED
|
|
570
|
+
when :delivers then block.call(connection)
|
|
571
|
+
else raise stop_outlet
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
576
|
+
expect(waits).to eq([1, 2, 1, 2])
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
context "when Better Stack answers with an error status" do
|
|
581
|
+
# Raised to leave the outlet's endless loop, see above.
|
|
582
|
+
let(:stop_outlet) { Class.new(Exception) }
|
|
583
|
+
let(:http_device) { described_class.new("MYKEY", flush_continuously: false, requests_per_conn: 1) }
|
|
584
|
+
let(:request_queue) { http_device.instance_variable_get(:@request_queue) }
|
|
585
|
+
let(:waits) { [] }
|
|
586
|
+
|
|
587
|
+
before do
|
|
588
|
+
allow(http_device).to receive(:sleep) { |seconds| waits << seconds }
|
|
589
|
+
# Leave the loop once there is nothing left to deliver.
|
|
590
|
+
allow(request_queue).to receive(:deq).and_wrap_original { |deq| deq.call || raise(stop_outlet) }
|
|
591
|
+
# A status is reported once per process, so none has been reported yet in each example.
|
|
592
|
+
stub_const("Logtail::LogDevices::HTTP::REPORTED_REJECTIONS", [])
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def queue_batch(size)
|
|
596
|
+
size.times { |i| http_device.write(Logtail::LogEntry.new("INFO", Time.now, nil, "line #{i}", nil, nil)) }
|
|
597
|
+
http_device.send(:flush_async)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
it "retries a batch answered with 500 and delivers it once" do
|
|
601
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return({status: 500}, {status: 202})
|
|
602
|
+
queue_batch(1)
|
|
603
|
+
|
|
604
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
605
|
+
expect(stub).to have_been_requested.twice
|
|
606
|
+
expect(waits).to eq([1])
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
it "retries a batch answered with 408 without a warning, and delivers it once" do
|
|
610
|
+
# Better Stack answers 408 when a new connection stays unused for more than about 10 seconds
|
|
611
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").
|
|
612
|
+
to_return({status: [408, "Request Time-out"], headers: {"Connection" => "close"}}, {status: 202})
|
|
613
|
+
queue_batch(1)
|
|
614
|
+
expect(http_device).not_to receive(:warn)
|
|
615
|
+
|
|
616
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
617
|
+
expect(stub).to have_been_requested.twice
|
|
618
|
+
expect(waits).to eq([1])
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
it "waits before retrying a batch answered with 429 at least as long as Retry-After says" do
|
|
622
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").
|
|
623
|
+
to_return({status: 429, headers: {"Retry-After" => "3"}}, {status: 202})
|
|
624
|
+
queue_batch(1)
|
|
625
|
+
|
|
626
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
627
|
+
expect(stub).to have_been_requested.twice
|
|
628
|
+
expect(waits).to eq([3])
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
it "reads Retry-After as a date too, and waits 60 seconds at most" do
|
|
632
|
+
Timecop.freeze(Time.utc(2026, 10, 1, 12, 0, 0)) do
|
|
633
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return(
|
|
634
|
+
{status: 503, headers: {"Retry-After" => (Time.now + 5).httpdate}},
|
|
635
|
+
{status: 503, headers: {"Retry-After" => "3600"}},
|
|
636
|
+
{status: 202}
|
|
637
|
+
)
|
|
638
|
+
queue_batch(1)
|
|
639
|
+
|
|
640
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
641
|
+
expect(stub).to have_been_requested.times(3)
|
|
642
|
+
expect(waits).to eq([5, 60])
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
it "drops a batch answered with 503 three times, backing off in between" do
|
|
647
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return(status: 503)
|
|
648
|
+
queue_batch(1)
|
|
649
|
+
expect(http_device).not_to receive(:warn)
|
|
650
|
+
|
|
651
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
652
|
+
expect(stub).to have_been_requested.times(3)
|
|
653
|
+
expect(waits).to eq([1, 2, 4])
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
it "drops batches answered with 401 and warns only once" do
|
|
657
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return(status: [401, "Unauthorized"])
|
|
658
|
+
queue_batch(2)
|
|
659
|
+
queue_batch(2)
|
|
660
|
+
expect(http_device).to receive(:warn).once.with("Logtail: Better Stack rejected 2 log lines with HTTP 401 " \
|
|
661
|
+
"Unauthorized - check your source token. Further rejections with this status won't be reported.")
|
|
662
|
+
|
|
663
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
664
|
+
expect(stub).to have_been_requested.twice
|
|
665
|
+
expect(waits).to eq([])
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
it "drops a batch answered with 413 and warns" do
|
|
669
|
+
stub = stub_request(:post, "https://in.logs.betterstack.com/").to_return(status: [413, "Payload Too Large"])
|
|
670
|
+
queue_batch(1)
|
|
671
|
+
|
|
672
|
+
expect do
|
|
673
|
+
expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
|
|
674
|
+
end.to output(/^Logtail: Better Stack rejected 1 log line with HTTP 413 Payload Too Large\. Further rejections with this status won't be reported\.$/).to_stderr
|
|
675
|
+
expect(stub).to have_been_requested.once
|
|
676
|
+
expect(waits).to eq([])
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
it "lets close stop the outlet while it waits to reconnect" do
|
|
681
|
+
connection_attempts = 0
|
|
682
|
+
allow_any_instance_of(Net::HTTP).to receive(:start) do
|
|
683
|
+
connection_attempts += 1
|
|
684
|
+
raise Errno::ECONNREFUSED
|
|
685
|
+
end
|
|
686
|
+
http_device = described_class.new("MYKEY")
|
|
687
|
+
http_device.send(:ensure_flush_threads_are_started)
|
|
688
|
+
outlet = http_device.instance_variable_get(:@request_outlet_thread)
|
|
689
|
+
# Up to 5 seconds for the thread's first attempt, which is slow on a cold TruffleRuby.
|
|
690
|
+
500.times do
|
|
691
|
+
break if connection_attempts > 0 && outlet.status == "sleep"
|
|
692
|
+
sleep 0.01
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
closing = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
696
|
+
http_device.close
|
|
697
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - closing).to be < 0.5
|
|
698
|
+
expect(outlet).not_to be_alive
|
|
699
|
+
expect(connection_attempts).to eq(1)
|
|
700
|
+
end
|
|
151
701
|
end
|
|
152
702
|
|
|
153
703
|
describe "#deliver_requests" do
|
|
@@ -32,5 +32,78 @@ describe Logtail::LogEntry do
|
|
|
32
32
|
expect(hash[:context][:runtime][:frame_label]).to_not be_nil
|
|
33
33
|
expect(hash[:context][:runtime][:frame_label].encoding.to_s).to eq('UTF-8')
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
context "with a context logged by the user" do
|
|
37
|
+
let(:context_snapshot) { {system: {hostname: "computer-name", pid: 123}, runtime: {thread_id: 456}} }
|
|
38
|
+
|
|
39
|
+
it "should keep the user's context next to the system and runtime context" do
|
|
40
|
+
log_entry = described_class.new("INFO", time, nil, "log message", context_snapshot, {context: {tenant: 1}})
|
|
41
|
+
|
|
42
|
+
context = log_entry.to_hash[:context]
|
|
43
|
+
expect(context[:tenant]).to eq(1)
|
|
44
|
+
expect(context[:system]).to eq(hostname: "computer-name", pid: 123)
|
|
45
|
+
expect(context[:runtime]).to eq(thread_id: 456)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "should merge nested hashes, keeping the gem's own values on conflict" do
|
|
49
|
+
user_context = {system: {hostname: "spoofed", region: "eu-west"}, runtime: {worker: "w1"}, job: {id: 7}}
|
|
50
|
+
log_entry = described_class.new("INFO", time, nil, "log message", context_snapshot, {context: user_context})
|
|
51
|
+
|
|
52
|
+
context = log_entry.to_hash[:context]
|
|
53
|
+
expect(context[:system]).to eq(hostname: "computer-name", pid: 123, region: "eu-west")
|
|
54
|
+
expect(context[:runtime]).to eq(thread_id: 456, worker: "w1")
|
|
55
|
+
expect(context[:job]).to eq(id: 7)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "should not modify the user's context or the current context" do
|
|
59
|
+
user_context = {runtime: {worker: "w1"}}
|
|
60
|
+
Logtail::CurrentContext.with(runtime: {thread_id: 456}) do
|
|
61
|
+
log_entry = Logtail::Logger::PassThroughFormatter.new.call("INFO", time, "", {message: "log message", context: user_context})
|
|
62
|
+
|
|
63
|
+
runtime_context = log_entry.to_hash[:context][:runtime]
|
|
64
|
+
expect(runtime_context).to include(thread_id: 456, worker: "w1")
|
|
65
|
+
expect(runtime_context[:file]).to end_with('/spec/logtail/log_entry_spec.rb')
|
|
66
|
+
expect(user_context).to eq(runtime: {worker: "w1"})
|
|
67
|
+
expect(Logtail::CurrentContext.instance.snapshot[:runtime]).to eq(thread_id: 456)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "should ignore a context that isn't a Hash" do
|
|
72
|
+
log_entry = described_class.new("INFO", time, nil, "log message", context_snapshot, {context: "checkout"})
|
|
73
|
+
expect(log_entry.to_hash[:context]).to eq(context_snapshot)
|
|
74
|
+
|
|
75
|
+
log_entry = described_class.new("INFO", time, nil, "log message", nil, {context: "checkout"})
|
|
76
|
+
expect(log_entry.to_hash[:context]).to eq(runtime: {})
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# logtail-rails logs Rails.event events this way, with the context set by Rails.event.set_context
|
|
80
|
+
it "should deliver the context of an event" do
|
|
81
|
+
http_device = Logtail::LogDevices::HTTP.new("MYKEY", flush_continuously: false)
|
|
82
|
+
logger = Logtail::Logger.new(http_device)
|
|
83
|
+
Logtail::CurrentContext.with(system: {hostname: "computer-name.domain.com", pid: 123}) do
|
|
84
|
+
logger.info("[order.placed] id=1", event_name: "order.placed", payload: {id: 1},
|
|
85
|
+
context: {request_id: "abc-123", shop_id: 42}, tags: [], source_location: {})
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
http_device.send(:flush_async)
|
|
89
|
+
request = http_device.instance_variable_get(:@request_queue).deq.request
|
|
90
|
+
delivered = MessagePack.unpack(Zlib::Inflate.inflate(request.body)).first
|
|
91
|
+
expect(delivered["event_name"]).to eq("order.placed")
|
|
92
|
+
expect(delivered["context"]).to include("request_id" => "abc-123", "shop_id" => 42)
|
|
93
|
+
expect(delivered["context"]["system"]).to eq("hostname" => "computer-name.domain.com", "pid" => 123)
|
|
94
|
+
expect(delivered["context"]["runtime"]).to include("file", "line")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe "#message" do
|
|
100
|
+
it "cuts a long message on a character boundary" do
|
|
101
|
+
# The 4-byte emoji takes bytes 8,191 to 8,194, across the 8,192-byte limit.
|
|
102
|
+
message = "a" * 8190 + "\u{1F600} and more"
|
|
103
|
+
log_entry = described_class.new("INFO", time, nil, message, nil, nil)
|
|
104
|
+
|
|
105
|
+
expect(log_entry.message.bytesize).to eq(8190)
|
|
106
|
+
expect(log_entry.message.valid_encoding?).to be(true)
|
|
107
|
+
end
|
|
35
108
|
end
|
|
36
109
|
end
|