logtail 0.1.18 → 0.1.20

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93f4739daeaac5fe30db5251e9463e4139adcb9fe1bb5cc06988bd2617827d1c
4
- data.tar.gz: 7de7ff6b0b83de585e95c70c2fea5ce2e2c7067592d6f18f8d139db665525c75
3
+ metadata.gz: 0d8397873f23b58750258e46a3162866eaa3ab6ffaf551078d28b25b44b5ad69
4
+ data.tar.gz: 83a5def59147d9b94a890db6e7f07dd2107bdfa68ba7eb929ca47fa5a44a212b
5
5
  SHA512:
6
- metadata.gz: 80fe56d644e5f07108859de6399c29f16fd26b7294b5ea63bc8b710b4ad9de757048e61c89e3e27837643c61d63525c79c60d9384c338c410daf8dacc840cac4
7
- data.tar.gz: 64b96d54f5531470e8b87cb0e2ba4515266b25a5c03c20828cc3d1fb8e21ac81dc686057ac4b927b5bb55f571ef7c1f1b0e22265eaf342e5302d746d00ff0222
6
+ metadata.gz: d710225c254e58dbdfa66e6e87515167fcc651ce4cd14d7ddeb6cb682d9b547f8eba13c9729a939249ca565229fcd68e4f9867db17900e798051abe5b0699473
7
+ data.tar.gz: 5d785a4b048a73403e2d389780d5e83702b0ada279fb2dd727e2b192176f15133620668e6f2a6eccdb5b142a29d9283fd03a3058f2d3f31aace47f42794ca820
@@ -1,10 +1,11 @@
1
- # Publishes the gem to RubyGems.org from GitHub Actions, the way logtail-js and logtail-python
2
- # release their packages. The publish job runs in the "rubygems" environment, which only deploys
3
- # from main; anyone who can dispatch the workflow can release.
1
+ # Publishes the gem to RubyGems.org from GitHub Actions with trusted publishing (OIDC), the way
2
+ # logtail-js releases the @logtail/* packages to npm. No RubyGems.org API key exists anywhere:
3
+ # the gem on rubygems.org trusts exactly this workflow file, run from this repository in the
4
+ # "rubygems" environment, and the shared account's multi-factor authentication stays as it is.
4
5
  #
5
- # Authentication: the repository secret RUBYGEMS_CREDENTIALS holds the contents of a gem
6
- # credentials file for the shared rubygems.org account, i.e. ":rubygems_api_key: rubygems_…",
7
- # the line the manual process kept in ~/.local/share/gem/credentials.
6
+ # One-time setup on rubygems.org, signed in as the gem's owner: the gem's page → Trusted publishers
7
+ # → GitHub Actions, with the repository owner "logtail", this repository's name, the workflow
8
+ # "release.yml" and the environment "rubygems".
8
9
  #
9
10
  # Repository settings this relies on: the "rubygems" environment with deployment branches limited
10
11
  # to main and no required reviewers (add reviewers there if releases should need an approval; a
@@ -17,15 +18,17 @@
17
18
  # both, pushes the gem to RubyGems.org and creates a GitHub release with auto-generated notes for
18
19
  # the tag; edit the notes afterwards if needed.
19
20
  #
20
- # Retry: if a release failed after the version commit was pushed, run "retry" right away from
21
- # main. It bumps nothing, rebuilds the tagged commit, pushes the gem if RubyGems.org is still
22
- # missing it and creates the GitHub release if it is still missing. Running patch or minor again
23
- # would release the next version instead.
21
+ # Retry: if a release failed after the version commit was pushed, run "retry" from main. It bumps
22
+ # nothing, checks out the tag of the version in lib/*/version.rb and rebuilds that commit, pushes
23
+ # the gem if RubyGems.org is still missing it and creates the GitHub release if it is still
24
+ # missing. Running patch or minor again would release the next version instead.
24
25
  #
25
- # Dry run: bumps in place without committing, builds the gem and checks the credentials secret,
26
- # publishes nothing. Every push that touches this file is a dry run, so a change to the workflow
27
- # proves itself on its pull request before it reaches main. A dry run can also be dispatched from
28
- # any branch.
26
+ # Dry run: bumps in place without committing, builds the gem and, on main, exchanges the
27
+ # workflow's OIDC token with RubyGems.org, which proves that the trusted publisher matches this
28
+ # workflow. Nothing is pushed. Every push that touches this file is a dry run, so a change to the
29
+ # workflow proves itself on its pull request before it reaches main, and the push that merges it
30
+ # performs the token exchange as well, because the "rubygems" environment only admits main. A dry
31
+ # run can also be dispatched from any branch.
29
32
  name: Release
30
33
 
31
34
  on:
@@ -37,7 +40,7 @@ on:
37
40
  options: [patch, minor, retry]
38
41
  required: true
39
42
  dry_run:
40
- description: "Dry run: bump and build, check the credentials, publish nothing"
43
+ description: "Dry run: bump and build, verify RubyGems.org accepts this workflow, publish nothing"
41
44
  type: boolean
42
45
  default: false
43
46
  push:
@@ -108,7 +111,9 @@ jobs:
108
111
  tagged = `git tag --points-at HEAD`.split.include?("v#{current}")
109
112
 
110
113
  if release == "retry"
111
- abort("retry only finishes a release whose version commit v#{current} is HEAD") unless tagged
114
+ # main may have moved on since the version commit, so build what the tag points at
115
+ abort("retry only finishes a release whose version v#{current} is tagged") unless system("git", "rev-parse", "-q", "--verify", "refs/tags/v#{current}", out: File::NULL)
116
+ system("git", "checkout", "-q", "v#{current}") || abort("could not check out v#{current}")
112
117
  version = current
113
118
  else
114
119
  abort("HEAD is already released as v#{current}, there is nothing new to release") if tagged && !dry_run
@@ -126,6 +131,7 @@ jobs:
126
131
  env:
127
132
  VERSION: ${{ steps.version.outputs.version }}
128
133
  run: |
134
+ git diff --stat
129
135
  gem build *.gemspec
130
136
  gem=$(ls *.gem)
131
137
  case "$gem" in
@@ -134,18 +140,6 @@ jobs:
134
140
  esac
135
141
  echo "gem=$gem" >> "$GITHUB_OUTPUT"
136
142
 
137
- - name: Dry run
138
- if: ${{ env.DRY_RUN == 'true' }}
139
- env:
140
- RUBYGEMS_CREDENTIALS: ${{ secrets.RUBYGEMS_CREDENTIALS }}
141
- run: |
142
- git diff --stat
143
- if ! grep -q '^:rubygems_api_key: [^ ]' <<< "$RUBYGEMS_CREDENTIALS"; then
144
- echo "::error::The RUBYGEMS_CREDENTIALS secret must hold the contents of a gem credentials file: ':rubygems_api_key: rubygems_…'"
145
- exit 1
146
- fi
147
- echo "RUBYGEMS_CREDENTIALS looks like a gem credentials file. A release pushes with it, nothing more to verify without pushing."
148
-
149
143
  - name: Commit and tag
150
144
  if: ${{ env.DRY_RUN != 'true' && env.RELEASE != 'retry' }}
151
145
  env:
@@ -166,11 +160,13 @@ jobs:
166
160
  release:
167
161
  name: Publish
168
162
  needs: build
169
- if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run }}
163
+ # The rubygems environment only admits main, so dry runs from other branches stop after the build
164
+ if: ${{ github.ref == 'refs/heads/main' }}
170
165
  runs-on: ubuntu-24.04
171
166
  environment: rubygems
172
167
  permissions:
173
168
  contents: write # creates the GitHub release
169
+ id-token: write # OIDC token exchange with RubyGems.org
174
170
  env:
175
171
  VERSION: ${{ needs.build.outputs.version }}
176
172
  GEM: ${{ needs.build.outputs.gem }}
@@ -185,20 +181,28 @@ jobs:
185
181
  with:
186
182
  ruby-version: "3"
187
183
 
184
+ # Exchanges the job's OIDC token for a short-lived RubyGems.org API key and hands it to
185
+ # `gem push`. Fails when no trusted publisher on rubygems.org matches this workflow.
186
+ - uses: rubygems/configure-rubygems-credentials@v2.1.0
187
+
188
+ - name: Dry run
189
+ if: ${{ env.DRY_RUN == 'true' }}
190
+ run: |
191
+ ls -l "$GEM"
192
+ echo "RubyGems.org accepted the OIDC token: the trusted publisher matches this workflow. Nothing is pushed."
193
+
188
194
  - name: Push to RubyGems.org
189
- env:
190
- RUBYGEMS_CREDENTIALS: ${{ secrets.RUBYGEMS_CREDENTIALS }}
195
+ if: ${{ env.DRY_RUN != 'true' }}
191
196
  run: |
192
197
  name="${GEM%-$VERSION.gem}"
193
198
  if gem specification --remote "$name" --version "$VERSION" > /dev/null 2>&1; then
194
199
  echo "$name $VERSION is on rubygems.org already, finishing the release."
195
200
  else
196
- mkdir -p ~/.gem
197
- (umask 077 && printf '%s\n' "$RUBYGEMS_CREDENTIALS" > ~/.gem/credentials)
198
201
  gem push "$GEM"
199
202
  fi
200
203
 
201
204
  - name: Create GitHub release
205
+ if: ${{ env.DRY_RUN != 'true' }}
202
206
  env:
203
207
  GH_TOKEN: ${{ github.token }}
204
208
  GH_REPO: ${{ github.repository }}
@@ -32,6 +32,10 @@ module Logtail
32
32
 
33
33
  attr_writer :http_body_limit
34
34
 
35
+ def initialize
36
+ @debug_logger = nil
37
+ end
38
+
35
39
  # Whether a particular {Logtail::LogEntry} should be sent to Better Stack
36
40
  def send_to_better_stack?(log_entry)
37
41
  !@better_stack_filters&.any? { |blocker| blocker.call(log_entry) }
@@ -22,6 +22,8 @@ module Logtail
22
22
  DEFAULT_INGESTING_SCHEME = "https".freeze
23
23
  CONTENT_TYPE = "application/msgpack".freeze
24
24
  USER_AGENT = "Logtail Ruby/#{Logtail::VERSION} (HTTP)".freeze
25
+ INITIAL_RECONNECT_WAIT = 1 # second
26
+ MAX_RECONNECT_WAIT = 30 # seconds
25
27
 
26
28
  # Instantiates a new HTTP log device that can be passed to {Logtail::Logger#initialize}.
27
29
  #
@@ -83,6 +85,7 @@ module Logtail
83
85
  @request_queue = options[:request_queue] || FlushableDroppingSizedQueue.new(25)
84
86
  @successive_error_count = 0
85
87
  @requests_in_flight = 0
88
+ @reconnect_wait = INITIAL_RECONNECT_WAIT
86
89
  end
87
90
 
88
91
  # Write a new log line message to the buffer, and flush asynchronously if the
@@ -119,13 +122,13 @@ module Logtail
119
122
  # Closes the log device, cleans up, and attempts one last delivery.
120
123
  def close
121
124
  # Kill the flush thread immediately since we are about to flush again.
122
- @flush_thread.kill if @flush_thread
125
+ @flush_thread.kill.join if @flush_thread
123
126
 
124
127
  # Flush all remaining messages
125
128
  flush
126
129
 
127
130
  # Kill the request queue thread. Flushing ensures that no requests are pending.
128
- @request_outlet_thread.kill if @request_outlet_thread
131
+ @request_outlet_thread.kill.join if @request_outlet_thread
129
132
  end
130
133
 
131
134
  def deliver_one(msg)
@@ -298,15 +301,19 @@ Logtail::Config.instance.debug_logger = ::Logger.new(STDOUT)
298
301
  http
299
302
  end
300
303
 
301
- # Creates a loop that processes the `@request_queue` on an interval.
304
+ # Creates a loop that processes the `@request_queue` on an interval. After a failed
305
+ # connection it waits before reconnecting, twice as long after every consecutive
306
+ # failure up to {MAX_RECONNECT_WAIT}, so an unreachable host is not retried in a busy
307
+ # loop. A delivered request starts the wait over (see {#deliver_requests}).
302
308
  def request_outlet
303
309
  loop do
304
310
  http = build_http
311
+ connection_healthy = false
305
312
 
306
313
  begin
307
314
  Logtail::Config.instance.debug { "Starting HTTP connection" }
308
315
 
309
- http.start do |conn|
316
+ connection_healthy = http.start do |conn|
310
317
  deliver_requests(conn)
311
318
  end
312
319
  rescue => e
@@ -315,6 +322,12 @@ Logtail::Config.instance.debug_logger = ::Logger.new(STDOUT)
315
322
  Logtail::Config.instance.debug { "Finishing HTTP connection" }
316
323
  http.finish if http.started?
317
324
  end
325
+
326
+ next if connection_healthy
327
+
328
+ Logtail::Config.instance.debug { "Reconnecting in #{@reconnect_wait} seconds" }
329
+ sleep(@reconnect_wait)
330
+ @reconnect_wait = [@reconnect_wait * 2, MAX_RECONNECT_WAIT].min
318
331
  end
319
332
  end
320
333
 
@@ -360,6 +373,7 @@ Logtail::Config.instance.debug_logger = ::Logger.new(STDOUT)
360
373
  num_reqs += 1
361
374
 
362
375
  @last_resp = resp
376
+ @reconnect_wait = INITIAL_RECONNECT_WAIT
363
377
 
364
378
  Logtail::Config.instance.debug do
365
379
  if resp.code == "202"
@@ -1,3 +1,3 @@
1
1
  module Logtail
2
- VERSION = "0.1.18"
2
+ VERSION = "0.1.20"
3
3
  end
@@ -0,0 +1,16 @@
1
+ require "spec_helper"
2
+
3
+ describe Logtail::Config do
4
+ describe "#debug_logger" do
5
+ # Ruby before 3.0 warns under `ruby -w` when an unset instance variable is read, and the
6
+ # HTTP device reads the debug logger on every connection attempt.
7
+ it "can be read before it is set without an uninitialized instance variable warning" do
8
+ config = described_class.send(:new)
9
+ verbose, $VERBOSE = $VERBOSE, true
10
+
11
+ expect { config.debug_logger }.not_to output.to_stderr
12
+ ensure
13
+ $VERBOSE = verbose
14
+ end
15
+ end
16
+ end
@@ -57,10 +57,8 @@ describe Logtail::LogDevices::HTTP do
57
57
  http.send(:ensure_flush_threads_are_started)
58
58
  http.close
59
59
  thread = http.instance_variable_get(:@flush_thread)
60
- sleep 0.1 # too fast!
61
60
  expect(thread).to_not be_alive
62
61
  thread = http.instance_variable_get(:@request_outlet_thread)
63
- sleep 0.1 # too fast!
64
62
  expect(thread).to_not be_alive
65
63
  end
66
64
 
@@ -150,6 +148,87 @@ describe Logtail::LogDevices::HTTP do
150
148
 
151
149
  http.close
152
150
  end
151
+
152
+ context "when connecting or delivering fails" do
153
+ # Raised from the stubs below to leave the outlet's endless loop. It is not a
154
+ # StandardError, so the outlet's own `rescue => e` lets it through.
155
+ let(:stop_outlet) { Class.new(Exception) }
156
+ let(:http_device) { described_class.new("MYKEY", flush_continuously: false, requests_per_conn: 1) }
157
+ let(:request_queue) { http_device.instance_variable_get(:@request_queue) }
158
+ let(:waits) { [] }
159
+
160
+ before do
161
+ allow(http_device).to receive(:sleep) { |seconds| waits << seconds }
162
+ end
163
+
164
+ it "waits before reconnecting, twice as long after every refused connection, up to 30 seconds" do
165
+ connection_attempts = 0
166
+ allow_any_instance_of(Net::HTTP).to receive(:start) do
167
+ connection_attempts += 1
168
+ raise stop_outlet if connection_attempts > 7
169
+ raise Errno::ECONNREFUSED
170
+ end
171
+
172
+ expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
173
+ expect(waits).to eq([1, 2, 4, 8, 16, 30, 30])
174
+ end
175
+
176
+ it "waits the same way when a request fails on an open connection, and still drops it after 3 attempts" do
177
+ request_queue.enq(Logtail::LogDevices::HTTP::RequestAttempt.new(Net::HTTP::Post.new("/")))
178
+ request_attempts = 0
179
+ allow_any_instance_of(Net::HTTP).to receive(:request) do
180
+ request_attempts += 1
181
+ raise Errno::ECONNRESET
182
+ end
183
+ allow(http_device).to receive(:sleep) do |seconds|
184
+ waits << seconds
185
+ raise stop_outlet if waits.size == 3
186
+ end
187
+
188
+ expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
189
+ expect(waits).to eq([1, 2, 4])
190
+ expect(request_attempts).to eq(3)
191
+ expect(request_queue.size).to eq(0)
192
+ end
193
+
194
+ it "starts over at 1 second once a request is delivered" do
195
+ request_queue.enq(Logtail::LogDevices::HTTP::RequestAttempt.new(Net::HTTP::Post.new("/")))
196
+ connection = double("connection", request: double("response", code: "202"))
197
+ connections = [:refused, :refused, :delivers, :refused, :refused]
198
+ allow_any_instance_of(Net::HTTP).to receive(:start) do |_http, &block|
199
+ case connections.shift
200
+ when :refused then raise Errno::ECONNREFUSED
201
+ when :delivers then block.call(connection)
202
+ else raise stop_outlet
203
+ end
204
+ end
205
+
206
+ expect { http_device.send(:request_outlet) }.to raise_error(stop_outlet)
207
+ expect(waits).to eq([1, 2, 1, 2])
208
+ end
209
+ end
210
+
211
+ it "lets close stop the outlet while it waits to reconnect" do
212
+ connection_attempts = 0
213
+ allow_any_instance_of(Net::HTTP).to receive(:start) do
214
+ connection_attempts += 1
215
+ raise Errno::ECONNREFUSED
216
+ end
217
+ http_device = described_class.new("MYKEY")
218
+ http_device.send(:ensure_flush_threads_are_started)
219
+ outlet = http_device.instance_variable_get(:@request_outlet_thread)
220
+ # Up to 5 seconds for the thread's first attempt, which is slow on a cold TruffleRuby.
221
+ 500.times do
222
+ break if connection_attempts > 0 && outlet.status == "sleep"
223
+ sleep 0.01
224
+ end
225
+
226
+ closing = Process.clock_gettime(Process::CLOCK_MONOTONIC)
227
+ http_device.close
228
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - closing).to be < 0.5
229
+ expect(outlet).not_to be_alive
230
+ expect(connection_attempts).to eq(1)
231
+ end
153
232
  end
154
233
 
155
234
  describe "#deliver_requests" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logtail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.18
4
+ version: 0.1.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Better Stack
@@ -188,6 +188,7 @@ files:
188
188
  - lib/logtail/version.rb
189
189
  - logtail.gemspec
190
190
  - spec/README.md
191
+ - spec/logtail/config_spec.rb
191
192
  - spec/logtail/current_context_spec.rb
192
193
  - spec/logtail/events/controller_call_spec.rb
193
194
  - spec/logtail/events/error_spec.rb