analytics-ruby 2.5.0 → 2.6.0

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: cec8be05188f102d0622c0c82771728e855506fb1d1872b6c83a6668515ee667
4
- data.tar.gz: 8ff1887eac5cdbf44ce702b6cbcf40fdbae60d52e8a4c6adade4637941e0ee04
3
+ metadata.gz: 93b810e0ef6d4ef896010dfc80c9997308f0761ac2f79345d96fa434f7dd66a3
4
+ data.tar.gz: e69ffd005078968e7137d220d8f9e51249a5452ae5311bc37757b21ea9cf82c4
5
5
  SHA512:
6
- metadata.gz: 94ea84292a05e8a922f82b59d2eed172202839918a59aa978c2fb00e083fb1620f81b86105b49c20607e318bcb8fe7d7a377e96df91c941a182cf23eddde1514
7
- data.tar.gz: add48055b0e80a97bba09e2b3278f0fee2b25b074591150efc079ed244fa8ae5d8c0829b7b22c465b93ea214db4dd4e5d029d2c78053c3a4cebd74c3db88fd8c
6
+ metadata.gz: 4619b6a5a332fd6da925f508c8e8ca58534236d597994ab0945840712d3761729a4c9dcf0a9aba2191e8f84f5191b853db9f1f854f047b00c46927ca98257c36
7
+ data.tar.gz: 9a6a6ca7a0c8c386dc9646c8521fb8000b183bd56d14d8da0a11e58117bd6beabd59048e8495f96b7acaab4624ad481c173dc3b781ca6dbbdcb24bd05d725a00
@@ -26,25 +26,17 @@ module Segment
26
26
  # @return [Numeric] the next backoff interval, in milliseconds.
27
27
  def next_interval
28
28
  interval = @min_timeout_ms * (@multiplier**@attempts)
29
- interval = add_jitter(interval, @randomization_factor)
30
-
31
29
  @attempts += 1
32
30
 
33
- [interval, @max_timeout_ms].min
31
+ # Clamp first, then jitter. Jittering before the clamp meant every attempt
32
+ # at the ceiling returned exactly max_timeout_ms, so a fleet that backed off
33
+ # together stayed in lockstep. Jitter only subtracts, so the ceiling holds.
34
+ capped = [interval, @max_timeout_ms].min
35
+ capped - (rand * capped * @randomization_factor)
34
36
  end
35
37
 
36
- private
37
-
38
- def add_jitter(base, randomization_factor)
39
- random_number = rand
40
- max_deviation = base * randomization_factor
41
- deviation = random_number * max_deviation
42
-
43
- if random_number < 0.5
44
- base - deviation
45
- else
46
- base + deviation
47
- end
38
+ def reset!
39
+ @attempts = 0
48
40
  end
49
41
  end
50
42
  end
@@ -32,7 +32,11 @@ module Segment
32
32
 
33
33
  check_write_key!
34
34
 
35
- at_exit { @worker_thread && @worker_thread[:should_exit] = true }
35
+ # The worker checks this between sleep slices, so a Retry-After or backoff
36
+ # wait is abandoned within a second rather than holding shutdown for up to
37
+ # rate_limit_retry_after_cap seconds. Assigning to a dead thread is safe;
38
+ # Thread#wakeup is not, and raising here would force a non-zero exit status.
39
+ at_exit { @worker_thread[:should_exit] = true if @worker_thread }
36
40
  end
37
41
 
38
42
  # Synchronously waits until the worker has flushed the queue.
@@ -12,6 +12,20 @@ module Segment
12
12
  'Content-Type' => 'application/json',
13
13
  'User-Agent' => "analytics-ruby/#{Analytics::VERSION}" }
14
14
  RETRIES = 10
15
+ MAX_TOTAL_BACKOFF_DURATION = 43_200 # 12 hours in seconds
16
+
17
+ # Rate-limited attempts are deliberately uncounted, so this duration is the
18
+ # only thing bounding them. It is deliberately several times
19
+ # RATE_LIMIT_RETRY_AFTER_CAP: when the two are equal a single maximal
20
+ # Retry-After consumes the whole budget, leaving one attempt and no retry,
21
+ # and the cap can never be the smaller of the two so it never binds at all.
22
+ MAX_RATE_LIMIT_DURATION = 1800 # seconds
23
+
24
+ # A guard against an absurd header, not a second budget. Waiting less than
25
+ # the server asked for does not make the next attempt more likely to
26
+ # succeed, it just sends more requests at something already rate-limiting
27
+ # us; how long we keep trying is MAX_RATE_LIMIT_DURATION's job.
28
+ RATE_LIMIT_RETRY_AFTER_CAP = 300 # seconds
15
29
  end
16
30
 
17
31
  module Queue
@@ -28,9 +42,9 @@ module Segment
28
42
  end
29
43
 
30
44
  module BackoffPolicy
31
- MIN_TIMEOUT_MS = 100
32
- MAX_TIMEOUT_MS = 10000
33
- MULTIPLIER = 1.5
45
+ MIN_TIMEOUT_MS = 500
46
+ MAX_TIMEOUT_MS = 60_000
47
+ MULTIPLIER = 2
34
48
  RANDOMIZATION_FACTOR = 0.5
35
49
  end
36
50
  end
@@ -12,6 +12,10 @@ module Segment
12
12
  @status = status
13
13
  @error = error
14
14
  end
15
+
16
+ def success?
17
+ status >= 200 && status < 300
18
+ end
15
19
  end
16
20
  end
17
21
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Segment
4
+ class Analytics
5
+ # Tracks the two independent budgets one send may spend.
6
+ #
7
+ # A retryable status carrying Retry-After spends the rate-limit budget, which
8
+ # is bounded by wall clock only. Anything else retryable spends the counted
9
+ # backoff budget, bounded by both a retry count and wall clock. Keeping them
10
+ # separate is what stops a rate-limited server from exhausting the retries
11
+ # available to genuine failures.
12
+ #
13
+ # The caller performs the wait, so both methods return the delay in seconds,
14
+ # or nil when the budget is spent and the batch should be abandoned.
15
+ class RetryBudget
16
+ attr_reader :retry_count
17
+
18
+ # Keyword arguments would be cleaner but need Ruby 2.1; the gemspec still
19
+ # declares >= 2.0, which is also what rubocop is configured to parse.
20
+ def initialize(options = {})
21
+ @retries_remaining = options[:retries]
22
+ @backoff_policy = options[:backoff_policy]
23
+ @max_total_backoff_duration = options[:max_total_backoff_duration]
24
+ @max_rate_limit_duration = options[:max_rate_limit_duration]
25
+ @rate_limit_retry_after_cap = options[:rate_limit_retry_after_cap]
26
+ @logger = options[:logger]
27
+ @retry_count = 0
28
+ @backoff_start_time = nil
29
+ @rate_limit_start_time = nil
30
+ end
31
+
32
+ def next_backoff_delay
33
+ # Checked before the decrement: decrementing first spent one retry on the
34
+ # exhaustion test itself, so a configured N only ever performed N-1, and
35
+ # retries: 1 and retries: 0 were indistinguishable.
36
+ return spent('Retries exhausted for batch') if @retries_remaining <= 0
37
+
38
+ @retries_remaining -= 1
39
+
40
+ # One reading for the budget test and the fit check below, as on the
41
+ # rate-limit path.
42
+ now = monotonic_now
43
+ @backoff_start_time ||= now
44
+
45
+ remaining = @max_total_backoff_duration - (now - @backoff_start_time)
46
+ return spent('Max total backoff duration exceeded for batch') if remaining <= 0
47
+
48
+ # A backoff longer than what is left of the budget is a wait whose attempt
49
+ # can never run, so there is nothing to schedule.
50
+ delay = @backoff_policy.next_interval.to_f / 1000
51
+ return spent('Backoff interval does not fit the remaining budget') if delay > remaining
52
+
53
+ @logger.debug("Retrying request, #{@retries_remaining} retries left. Waiting #{delay}s")
54
+ delay
55
+ end
56
+
57
+ def next_rate_limit_delay(retry_after, status_code)
58
+ # One reading serves the episode start, the budget test and the delay. A
59
+ # second reading lets the budget expire between them, which yields a negative
60
+ # remaining and a negative delay — and Kernel#sleep raises ArgumentError on
61
+ # one rather than returning immediately. It also leaves remaining a hair under
62
+ # the budget on an episode's first response, which is enough to lose an exact
63
+ # comparison against the cap.
64
+ now = monotonic_now
65
+ @rate_limit_start_time ||= now
66
+
67
+ remaining = @max_rate_limit_duration - (now - @rate_limit_start_time)
68
+ return spent('Max rate limit duration exceeded for batch') if remaining <= 0
69
+
70
+ # Capped, then required to fit. Shortening the wait to fit would send the
71
+ # next request inside the window the server asked us to wait out — one it
72
+ # has already said it will not serve — and the budget is spent by then, so
73
+ # it would be the last attempt either way. Giving up loses the same batch
74
+ # and sends one request fewer at something already rate-limiting us.
75
+ delay = [retry_after, @rate_limit_retry_after_cap].min
76
+ return spent('Retry-After does not fit the remaining rate limit budget') if delay > remaining
77
+
78
+ @logger.debug("Retry-After: #{delay}s on #{status_code}. Retrying after delay.")
79
+ delay
80
+ end
81
+
82
+ def record_retry
83
+ @retry_count += 1
84
+ end
85
+
86
+ private
87
+
88
+ def elapsed?(start_time, limit)
89
+ (monotonic_now - start_time) >= limit
90
+ end
91
+
92
+ # Wall-clock time can jump; these budgets must not expire or stretch with it.
93
+ def monotonic_now
94
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
95
+ end
96
+
97
+ def spent(message)
98
+ @logger.error(message)
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -5,9 +5,11 @@ require 'segment/analytics/utils'
5
5
  require 'segment/analytics/response'
6
6
  require 'segment/analytics/logging'
7
7
  require 'segment/analytics/backoff_policy'
8
+ require 'segment/analytics/retry_budget'
8
9
  require 'net/http'
9
10
  require 'net/https'
10
11
  require 'json'
12
+ require 'time'
11
13
 
12
14
  module Segment
13
15
  class Analytics
@@ -16,22 +18,18 @@ module Segment
16
18
  include Segment::Analytics::Utils
17
19
  include Segment::Analytics::Logging
18
20
 
21
+ RETRYABLE_4XX = [408, 410, 429, 460].freeze
22
+ NON_RETRYABLE_5XX = [501, 505, 511].freeze
23
+
19
24
  def initialize(options = {})
20
25
  options[:host] ||= HOST
21
26
  options[:port] ||= PORT
22
- options[:ssl] ||= SSL
27
+ options[:ssl] ||= SSL
23
28
  @headers = options[:headers] || HEADERS
24
- @path = options[:path] || PATH
25
- @retries = options[:retries] || RETRIES
26
- @backoff_policy =
27
- options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
29
+ @path = options[:path] || PATH
28
30
 
29
- http = Net::HTTP.new(options[:host], options[:port])
30
- http.use_ssl = options[:ssl]
31
- http.read_timeout = 8
32
- http.open_timeout = 4
33
-
34
- @http = http
31
+ configure_retries(options)
32
+ @http = build_http(options)
35
33
  end
36
34
 
37
35
  # Sends a batch of messages to the API
@@ -40,23 +38,32 @@ module Segment
40
38
  def send(write_key, batch)
41
39
  logger.debug("Sending request for #{batch.length} items")
42
40
 
43
- last_response, exception = retry_with_backoff(@retries) do
44
- status_code, body = send_request(write_key, batch)
45
- error = JSON.parse(body)['error']
46
- should_retry = should_retry_request?(status_code, body)
47
- logger.debug("Response status code: #{status_code}")
48
- logger.debug("Response error: #{error}") if error
41
+ @backoff_policy.reset! if @backoff_policy.respond_to?(:reset!)
42
+ budget = new_retry_budget
49
43
 
50
- [Response.new(status_code, error), should_retry]
51
- end
44
+ loop do
45
+ begin
46
+ status_code, body, headers = send_request(write_key, batch, budget.retry_count)
47
+ rescue StandardError => e
48
+ # Connection reset, DNS failure, read timeout and friends. Retried on
49
+ # the counted backoff budget, like a retryable status code.
50
+ logger.error("Network error: #{e.message}")
51
+ return Response.new(-1, e.to_s) unless wait_to_retry(budget.next_backoff_delay, budget)
52
52
 
53
- if exception
54
- logger.error(exception.message)
55
- exception.backtrace.each { |line| logger.error(line) }
56
- Response.new(-1, exception.to_s)
57
- else
58
- last_response
53
+ next
54
+ end
55
+
56
+ error = parse_error(body)
57
+ final = final_response(status_code, body, error)
58
+ return final if final
59
+
60
+ delay = retry_delay(status_code, headers, budget)
61
+ return Response.new(status_code, error) unless wait_to_retry(delay, budget)
59
62
  end
63
+ rescue StandardError => e
64
+ logger.error(e.message)
65
+ e.backtrace.each { |line| logger.error(line) }
66
+ Response.new(-1, e.to_s)
60
67
  end
61
68
 
62
69
  # Closes a persistent connection if it exists
@@ -66,65 +73,174 @@ module Segment
66
73
 
67
74
  private
68
75
 
69
- def should_retry_request?(status_code, body)
70
- if status_code >= 500
71
- true # Server error
72
- elsif status_code == 429
73
- true # Rate limited
74
- elsif status_code >= 400
75
- logger.error(body)
76
- false # Client error. Do not retry, but log
76
+ # A Response once the batch is settled, or nil while it is still retryable.
77
+ def final_response(status_code, body, error)
78
+ logger.debug("Response status code: #{status_code}")
79
+ logger.debug("Response error: #{error}") if error
80
+
81
+ return Response.new(status_code, error) if success_status?(status_code)
82
+ return nil if retryable_status?(status_code)
83
+
84
+ if status_code >= 300 && status_code < 400
85
+ # Logging the body here would be useless: a redirect has none.
86
+ logger.error("Unexpected redirect (#{status_code}); batch not uploaded. " \
87
+ 'Check whether the configured host points at a proxy or redirector.')
77
88
  else
78
- false
89
+ logger.error(body)
79
90
  end
91
+ Response.new(status_code, error)
80
92
  end
81
93
 
82
- # Takes a block that returns [result, should_retry].
83
- #
84
- # Retries upto `retries_remaining` times, if `should_retry` is false or
85
- # an exception is raised. `@backoff_policy` is used to determine the
86
- # duration to sleep between attempts
94
+ # A Retry-After spends the rate-limit budget; anything else retryable
95
+ # spends the counted backoff budget.
96
+ def retry_delay(status_code, headers, budget)
97
+ retry_after = parse_retry_after(headers['retry-after'])
98
+ return budget.next_backoff_delay unless retry_after
99
+
100
+ budget.next_rate_limit_delay(retry_after, status_code)
101
+ end
102
+
103
+ def configure_retries(options)
104
+ @retries = options[:retries] || RETRIES
105
+ @backoff_policy =
106
+ options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
107
+
108
+ # One policy instance serves every batch, so it has to be reset between
109
+ # them or attempt counts accumulate and each batch starts where the last
110
+ # one left off. reset! is not part of the older documented contract, which
111
+ # was next_interval alone, so a policy predating it still works — but it
112
+ # keeps that accumulation, and silently. Say so rather than letting an
113
+ # integrator find it as "retries get slower the longer we run".
114
+ unless @backoff_policy.respond_to?(:reset!)
115
+ logger.warn(
116
+ 'backoff_policy does not implement reset!; attempt counts will ' \
117
+ 'accumulate across batches. Add a reset! method that clears them.'
118
+ )
119
+ end
120
+ @max_total_backoff_duration = options[:max_total_backoff_duration] ||
121
+ MAX_TOTAL_BACKOFF_DURATION
122
+ @max_rate_limit_duration = options[:max_rate_limit_duration] ||
123
+ MAX_RATE_LIMIT_DURATION
124
+ @rate_limit_retry_after_cap = options[:rate_limit_retry_after_cap] ||
125
+ RATE_LIMIT_RETRY_AFTER_CAP
126
+ end
127
+
128
+ def build_http(options)
129
+ http = Net::HTTP.new(options[:host], options[:port])
130
+ http.use_ssl = options[:ssl]
131
+ http.read_timeout = 8
132
+ http.open_timeout = 4
133
+ http
134
+ end
135
+
136
+ # How long a sliced retry wait sleeps before re-checking for shutdown.
137
+ SHUTDOWN_CHECK_INTERVAL = 1
138
+
139
+ def new_retry_budget
140
+ RetryBudget.new(
141
+ :retries => @retries,
142
+ :backoff_policy => @backoff_policy,
143
+ :max_total_backoff_duration => @max_total_backoff_duration,
144
+ :max_rate_limit_duration => @max_rate_limit_duration,
145
+ :rate_limit_retry_after_cap => @rate_limit_retry_after_cap,
146
+ :logger => logger
147
+ )
148
+ end
149
+
150
+ # nil delay means the budget is spent. Sleeping here rather than inside
151
+ # RetryBudget keeps the wait on Transport, where callers stub it.
152
+ def wait_to_retry(delay, budget)
153
+ return false if delay.nil?
154
+ return false unless interruptible_sleep(delay)
155
+
156
+ budget.record_retry
157
+ true
158
+ end
159
+
160
+ # Sleeps in slices so shutdown is noticed within SHUTDOWN_CHECK_INTERVAL
161
+ # rather than after the whole delay, which can be rate_limit_retry_after_cap
162
+ # seconds. Returns false if shutdown was requested.
87
163
  #
88
- # Returns [last_result, raised_exception]
89
- def retry_with_backoff(retries_remaining, &block)
90
- result, caught_exception = nil
91
- should_retry = false
164
+ # Thread#wakeup is deliberately not used for this: it only cuts short a sleep
165
+ # already in progress, so a wakeup arriving while the worker is mid-request is
166
+ # lost and the next sleep still runs in full.
167
+ def interruptible_sleep(seconds)
168
+ remaining = seconds
169
+ while remaining > 0
170
+ return false if Thread.current[:should_exit]
92
171
 
93
- begin
94
- result, should_retry = yield
95
- return [result, nil] unless should_retry
96
- rescue StandardError => e
97
- should_retry = true
98
- caught_exception = e
172
+ slice = [remaining, SHUTDOWN_CHECK_INTERVAL].min
173
+ sleep(slice)
174
+ remaining -= slice
99
175
  end
176
+ !Thread.current[:should_exit]
177
+ end
100
178
 
101
- if should_retry && (retries_remaining > 1)
102
- logger.debug("Retrying request, #{retries_remaining} retries left")
103
- sleep(@backoff_policy.next_interval.to_f / 1000)
104
- retry_with_backoff(retries_remaining - 1, &block)
179
+ def parse_error(body)
180
+ JSON.parse(body)['error']
181
+ rescue StandardError
182
+ nil
183
+ end
184
+
185
+ # Only 2xx. Net::HTTP does not follow redirects, so a 3xx means nothing was
186
+ # uploaded; calling it success would drop the batch silently.
187
+ def success_status?(code)
188
+ code >= 200 && code < 300
189
+ end
190
+
191
+ def retryable_status?(code)
192
+ if code >= 500 && code < 600
193
+ !NON_RETRYABLE_5XX.include?(code)
105
194
  else
106
- [result, caught_exception]
195
+ RETRYABLE_4XX.include?(code)
196
+ end
197
+ end
198
+
199
+ def parse_retry_after(value)
200
+ return nil if value.nil?
201
+
202
+ str = value.is_a?(Array) ? value.first : value
203
+ return nil if str.nil?
204
+
205
+ str = str.strip
206
+
207
+ # Try integer seconds
208
+ if str =~ /\A\d+\z/
209
+ seconds = str.to_i
210
+ return seconds > 0 ? seconds : nil
211
+ end
212
+
213
+ # Try HTTP-date (RFC 7231 S7.1.1.1)
214
+ begin
215
+ target = Time.httpdate(str)
216
+ seconds = (target - Time.now).to_i
217
+ seconds > 0 ? seconds : nil
218
+ rescue ArgumentError
219
+ nil
107
220
  end
108
221
  end
109
222
 
110
- # Sends a request for the batch, returns [status_code, body]
111
- def send_request(write_key, batch)
223
+ # Sends a request for the batch, returns [status_code, body, headers]
224
+ def send_request(write_key, batch, retry_count = 0)
112
225
  payload = JSON.generate(
113
226
  :sentAt => datetime_in_iso8601(Time.now),
114
227
  :batch => batch
115
228
  )
116
- request = Net::HTTP::Post.new(@path, @headers)
229
+ headers = @headers.dup
230
+ headers['X-Retry-Count'] = retry_count.to_s if retry_count > 0
231
+
232
+ request = Net::HTTP::Post.new(@path, headers)
117
233
  request.basic_auth(write_key, nil)
118
234
 
119
235
  if self.class.stub
120
236
  logger.debug "stubbed request to #{@path}: " \
121
237
  "write key = #{write_key}, batch = #{JSON.generate(batch)}"
122
238
 
123
- [200, '{}']
239
+ [200, '{}', {}]
124
240
  else
125
- @http.start unless @http.started? # Maintain a persistent connection
241
+ @http.start unless @http.started?
126
242
  response = @http.request(request, payload)
127
- [response.code.to_i, response.body]
243
+ [response.code.to_i, response.body, response.to_hash]
128
244
  end
129
245
  end
130
246
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Segment
4
4
  class Analytics
5
- VERSION = '2.5.0'
5
+ VERSION = '2.6.0'
6
6
  end
7
7
  end
@@ -45,7 +45,7 @@ module Segment
45
45
  end
46
46
 
47
47
  res = @transport.send @write_key, @batch
48
- @on_error.call(res.status, res.error) unless res.status == 200
48
+ @on_error.call(res.status, res.error) unless res.success?
49
49
 
50
50
  @lock.synchronize { @batch.clear }
51
51
  end
@@ -6,6 +6,7 @@ require 'segment/analytics/utils'
6
6
  require 'segment/analytics/field_parser'
7
7
  require 'segment/analytics/client'
8
8
  require 'segment/analytics/worker'
9
+ require 'segment/analytics/retry_budget'
9
10
  require 'segment/analytics/transport'
10
11
  require 'segment/analytics/response'
11
12
  require 'segment/analytics/logging'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: analytics-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Segment.io
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-10-07 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: commander
@@ -86,14 +86,14 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: 3.6.2
89
+ version: '3.6'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: 3.6.2
96
+ version: '3.6'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: rubocop
99
99
  requirement: !ruby/object:Gem::Requirement
@@ -126,6 +126,7 @@ files:
126
126
  - lib/segment/analytics/logging.rb
127
127
  - lib/segment/analytics/message_batch.rb
128
128
  - lib/segment/analytics/response.rb
129
+ - lib/segment/analytics/retry_budget.rb
129
130
  - lib/segment/analytics/test_queue.rb
130
131
  - lib/segment/analytics/transport.rb
131
132
  - lib/segment/analytics/utils.rb
@@ -135,7 +136,7 @@ homepage: https://github.com/segmentio/analytics-ruby
135
136
  licenses:
136
137
  - MIT
137
138
  metadata: {}
138
- post_install_message:
139
+ post_install_message:
139
140
  rdoc_options: []
140
141
  require_paths:
141
142
  - lib
@@ -150,8 +151,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
151
  - !ruby/object:Gem::Version
151
152
  version: '0'
152
153
  requirements: []
153
- rubygems_version: 3.1.6
154
- signing_key:
154
+ rubygems_version: 3.5.22
155
+ signing_key:
155
156
  specification_version: 4
156
157
  summary: Segment.io analytics library
157
158
  test_files: []