Exit quickly when Better Stack can't be reached, and deliver lines logged during shutdown - #58
Merged
Merged
Conversation
Every Logtail::Logger registers its own at_exit hook that closes the device, and close waits up to 20 seconds for an outlet that can't reach the host, so an unreachable host makes exit take 20 seconds per logger. A line logged while Ruby kills threads at exit prints "log writing failed. can't alloc thread" and is lost, and a line written after the device closed, e.g. by an earlier at_exit hook, vanishes. The exit tests run logtail in a separate Ruby process against a local ingesting server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each HTTP device registers one at_exit hook that closes it, instead of one hook per Logtail::Logger, and close does nothing the second time. - close stops waiting for the outlet thread once it can't deliver: the thread is dead, or the host has never answered and the outlet has backed off after a failed connection. A refused port or a failed DNS lookup now costs about a second at exit instead of 20 per logger. - A line written after close, or when Ruby refuses to start a thread while it shuts down, is delivered right away in the writing thread, with 2-second timeouts and errors only in the debug log, so Ruby's Logger no longer prints "log writing failed. can't alloc thread". Once such a delivery fails, later lines are dropped. - Ruby also refuses the thread Net::HTTP (before Ruby 4.0) needs to time out connecting then, so the device connects without that timeout, but only to a host that has answered before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A line written after close is delivered synchronously, which only rescued StandardError, so an error outside it, like the one WebMock refuses connections with, escaped write. Through Ruby's Logger that prints "log writing failed" to stderr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deliver_synchronously now rescues every Exception but signals and only writes it to the debug log, so a late line can't print "log writing failed" or raise into the app. Interrupt and other signals are raised as before. The helper stays the same as in the fork-safety PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With warnings on, Ruby 2.7 and older warn about every instance variable that is read before it's set. The device reads @closed on every write and in close, @last_resp while close waits, and @late_delivery_failed for a line written after close, before setting them. ruby -w on 2.7 printed these warnings, which 0.1.20 doesn't. The test fails on the Ruby 2.5 to 2.7 jobs only, newer Rubies don't warn about this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@closed, @late_delivery_failed and @last_resp are set in initialize, so Ruby 2.7 and older no longer warn that they are read before they're set. The warnings left with -w on Ruby 2.7 are the ones 0.1.20 prints too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz
added a commit
that referenced
this pull request
Oct 2, 2026
So Ruby 2.7 and older no longer warn that deliver_synchronously reads it before it's set. #58 sets it on the same line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz
marked this pull request as ready for review
October 2, 2026 09:17
…onds Lines logged after close are delivered in the calling thread. With the 2 second timeout a host that takes 3 seconds to answer fails the first delivery, and the next line is dropped. This fails until the timeout is raised to 5 seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 seconds left little room for a slow network. 5 seconds matches how long flush waits for the outlet, and still keeps exits short when Better Stack can't be reached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ueue The outlet takes a batch off the request queue before it counts it as in flight. When close checks in between, it sees an empty queue and nothing in flight, stops waiting and kills the outlet, and the batch is lost. The test widens that moment; it fails until the outlet counts the batch first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
close waits until the request queue is empty and nothing is in flight. The outlet used to take a batch off the queue first and count it afterwards, so close could see neither, kill the outlet and lose the batch at exit. Rare on MRI, but it made the exit tests flaky on TruffleRuby, which runs threads in parallel. It's the same in 0.1.20. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz
added a commit
that referenced
this pull request
Oct 2, 2026
2 seconds left little room for a slow network. 5 seconds matches how long flush waits for the outlet thread. #58 changes the same line the same way, so the two still merge cleanly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz
added a commit
that referenced
this pull request
Oct 2, 2026
With #58 merged, a device that was closed delivers each line on its own, and a forked child keeps the parent's closed state. So when the parent closes the device before forking, every line the child logs goes out as its own request. The local ingest server in the specs now records how many lines each request held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz
added a commit
that referenced
this pull request
Oct 2, 2026
Conflicts in lib/logtail/log_devices/http.rb, both sides kept: - require block: #54's require "set" and this branch's require "time" - constants after MAX_RECONNECT_WAIT: this branch's MAX_RETRY_AFTER, REPORTED_REJECTIONS and REPORTED_REJECTIONS_LOCK, then SYNCHRONOUS_DELIVERY_TIMEOUT from #57/#58 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz
added a commit
that referenced
this pull request
Oct 2, 2026
…ivery With #57 and #58 merged, a delivery in the calling thread (flush without an outlet thread, lines written after close or during shutdown) counts any HTTP response as delivered. So a batch rejected with 401 or 403 isn't reported, and lines written after close keep going out after a 429 or 5xx. Three of the four new examples fail for that. The fourth, a batch answered with 408, 429 or 5xx is neither retried nor reported, passes already and keeps the fix from reporting those statuses as rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every
Logtail::Logger.newregistered its ownat_exit { close }(logger.rb:196), andHTTP#closewaits up to 20 seconds (wait_on_request_queue) for an outlet thread that can't reach the ingesting host. With an unreachable host, exiting took 20 seconds per logger:rails runner,console,db:migrateandassets:precompileeach gained about 20 seconds in the red-team, and three loggers on one device took 60. The hooks also kept every logger alive until exit. A line logged while Ruby shuts down, e.g. by a gem's background thread in itsensureblock, madewritestart a thread, which Ruby refuses at that point: Ruby's Logger printedlog writing failed. can't alloc threadand the line was lost. A line written after the device closed, e.g. by an at_exit hook registered before the logger, vanished without a word.Reported in logtail/logtail-ruby-rails#42
Logtail::Loggerno longer registers one, andclosedoes nothing the second time.closestops waiting as soon as the outlet can't deliver: its thread is dead, or it has never had a response from the host and has backed off after a failed connection. A normal close still waits until everything is delivered.close, or when Ruby refuses to start a thread, is delivered right away in the thread that writes it, with 2-second connect and read timeouts. Errors only go to theLogtail::Configdebug log, never to stderr, also those outsideStandardError(WebMock refuses connections with one); signals such asInterruptare raised as usual. After one such delivery fails, later lines are dropped, so an unreachable host can't slow down the exit line by line.Net::HTTPneeds (before Ruby 4.0) to time out connecting. The device then connects without that timeout, but only to a host that has answered this process before; otherwise the line is dropped quietly.Behaviour and compatibility:
::Logger::LogDevicewrites synchronously, so nothing is lost, and STDOUT stays open for output from later at_exit hooks.closewaiting until the outlet's first connection attempt times out (10 seconds) and backs off.RbConfig.ruby -Ilibagainst a local TCP server on an ephemeral port.Locally, with the red-team's
manyloggers.rb(loggers sharing one device, refused port): main took 20.5 s to run and exit with one logger and 61.0 s with three, this branch 1.2 s with either, Ruby's start included. 100,000Logtail::Logger.newon one device leave 1 logger alive and add 1 MB RSS, where the red-team measured 115 MB on main. A script that logs from an at_exit hook registered before the logger, from a background thread'sensureat exit and from a finalizer: on main, Ruby's Logger printedlog writing failed. can't alloc threadtwice and only the regular line arrived; here stderr stays empty and all four lines arrive, on Ruby 2.7, 3.0, 3.2, 3.3 and 3.4.#57 (fork safety and
Logger#flush) also changeshttp.rb. Both PRs add the samedeliver_synchronously(requests)helper,SYNCHRONOUS_DELIVERY_TIMEOUTandspec/support/processes.rb, and otherwise touch different lines, so they merge cleanly in either order; a local merge of both branches passes the suite on Ruby 2.7, 3.0, 3.2 and 3.4. With both,closealso delivers synchronously when the outlet thread is gone, and waits 5 seconds at most.Follow-up after the E2E run of the release candidate (two more commits, the first one only a test, red on the Ruby 2.5 to 2.7 jobs): with warnings on (
ruby -w), Ruby 2.7 and older warned that@closed,@last_respand@late_delivery_failedwere read before they were set: on every write, whileclosewaits, and for a line written afterclose. They're set ininitializenow. The warnings left with-won Ruby 2.7 are the ones 0.1.20 prints too. #57 sets@last_respon the same line, so the two still merge cleanly.Targets the logtail 0.1.21 patch release. No dependencies on the other open PRs.
The first commit only adds the tests and is expected to fail on CI; the fix follows in the next commit. The last two add a failing test and the fix for an error outside
StandardErrorescaping the delivery of a late line.Follow-up (062d287 test, 6d27d8f change): synchronous deliveries now wait up to 5 seconds to connect and 5 seconds for the response (
SYNCHRONOUS_DELIVERY_TIMEOUT, previously 2), Petr's choice. They cover lines logged afterclose, lines logged while Ruby refuses new threads at exit, andflushwithout an outlet thread. Normal delivery from the outlet thread keeps its 10 second connect and 30 second read timeouts. The new test logs two lines aftercloseto a host that answers after 3 seconds; with 2 seconds the first delivery timed out and the second line was dropped.Follow-up (fd9e3f2 test, b866aa7 fix):
closewaits until the request queue is empty and nothing is in flight. The outlet used to take a batch off the queue first and only then count it as in flight. Ifclosechecked in between, it saw neither, killed the outlet, and the batch was lost at exit. The same code is in 0.1.20. On MRI the window is tiny, but it made "closes a device once at exit" flaky on TruffleRuby, which runs threads in parallel (failed once on 6d27d8f, passed on re-run). The test widens the window: the line was lost 3 times out of 3 before the fix and delivered after it. The outlet now counts the batch as in flight before it takes it off the queue.🤖 Generated with Claude Code