logtail 0.1.20 → 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.
@@ -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) }
@@ -208,6 +577,106 @@ describe Logtail::LogDevices::HTTP do
208
577
  end
209
578
  end
210
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
+
211
680
  it "lets close stop the outlet while it waits to reconnect" do
212
681
  connection_attempts = 0
213
682
  allow_any_instance_of(Net::HTTP).to receive(:start) 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
@@ -0,0 +1,82 @@
1
+ require "spec_helper"
2
+
3
+ # Rails flushes Rails.logger after every request (Rails::Rack::Logger calls
4
+ # ActiveSupport::LogSubscriber.flush_all!), and Logger#flush returns right away for that call
5
+ # instead of waiting for delivery. These run ActiveSupport's real flush_all! in another process, so
6
+ # an ActiveSupport that calls flush differently fails here. The ingesting host holds its answers
7
+ # until the direct flush, so a flush_all! that waited for delivery would take 5 seconds.
8
+ describe Logtail::Logger, "#flush called by ActiveSupport::LogSubscriber.flush_all!" do
9
+ {
10
+ "a Logtail::Logger" => "logger",
11
+ "a Logtail::Logger in ActiveSupport::TaggedLogging" => "ActiveSupport::TaggedLogging.new(logger)",
12
+ "a Logtail::Logger in an ActiveSupport::BroadcastLogger, as Rails 7.1 and later wrap it" =>
13
+ "ActiveSupport::BroadcastLogger.new(logger)",
14
+ "a Logtail::Logger in ActiveSupport::TaggedLogging in an ActiveSupport::BroadcastLogger" =>
15
+ "ActiveSupport::BroadcastLogger.new(ActiveSupport::TaggedLogging.new(logger))"
16
+ }.each do |description, rails_logger|
17
+ it "returns right away for #{description}, and flushing it directly delivers" do
18
+ if rails_logger.include?("BroadcastLogger") && Gem.loaded_specs["activesupport"].version < Gem::Version.new("7.1")
19
+ skip "ActiveSupport::BroadcastLogger was added in ActiveSupport 7.1"
20
+ end
21
+
22
+ result = run_ruby(<<-RUBY)
23
+ require "logger" # ActiveSupport 6.1 needs it loaded first since concurrent-ruby 1.3.5
24
+ require "active_support"
25
+ require "active_support/log_subscriber"
26
+ require "active_support/tagged_logging"
27
+ require "json"
28
+ require "logtail"
29
+
30
+ # The ingesting host keeps the lines of every request, but answers only once `answering` is set
31
+ delivered = []
32
+ answering = false
33
+ server = TCPServer.new("127.0.0.1", 0)
34
+ Thread.new do
35
+ loop do
36
+ Thread.new(server.accept) do |socket|
37
+ while socket.gets
38
+ length = 0
39
+ while (header = socket.gets) != "\\r\\n"
40
+ length = header.split(":")[1].to_i if header.downcase.start_with?("content-length:")
41
+ end
42
+ delivered.concat(MessagePack.unpack(Zlib::Inflate.inflate(socket.read(length))).map { |line| line["message"] })
43
+ sleep 0.01 until answering
44
+ socket.write("HTTP/1.1 202 Accepted\\r\\nContent-Length: 0\\r\\n\\r\\n")
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ # Logs a line for every request, like Rails' ActionController::LogSubscriber
51
+ class RequestLogSubscriber < ActiveSupport::LogSubscriber
52
+ def request(event)
53
+ info(event.payload[:message])
54
+ end
55
+ end
56
+ RequestLogSubscriber.attach_to(:app)
57
+
58
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", flush_interval: 60,
59
+ ingesting_host: "127.0.0.1", ingesting_port: server.addr[1], ingesting_scheme: "http"))
60
+ ActiveSupport::LogSubscriber.logger = #{rails_logger}
61
+ ActiveSupport::Notifications.instrument("request.app", message: "Completed 200 OK")
62
+
63
+ flushing = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ ActiveSupport::LogSubscriber.flush_all!
65
+ flush_all_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - flushing
66
+ delivered_by_flush_all = delivered.dup
67
+
68
+ answering = true
69
+ ActiveSupport::LogSubscriber.logger.flush
70
+ puts JSON.generate("flush_all_seconds" => flush_all_seconds, "delivered_by_flush_all" => delivered_by_flush_all,
71
+ "delivered_by_flush" => delivered.drop(delivered_by_flush_all.size))
72
+ RUBY
73
+
74
+ expect(result.status).to be_success, result.stderr
75
+ expect(JSON.parse(result.stdout)).to match(
76
+ "flush_all_seconds" => a_value < 1,
77
+ "delivered_by_flush_all" => [],
78
+ "delivered_by_flush" => ["Completed 200 OK"]
79
+ )
80
+ end
81
+ end
82
+ end