libhoney 1.16.0 → 1.19.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9034a56adcdd87d45a5d7602caa9add401b713cb52ba258d0383835b30304ef5
4
- data.tar.gz: 4db352186d89946e6121ace449f69d869175ce99cb3910084b35491bbbbc26a8
3
+ metadata.gz: 40d54151fc103bf8020f14ff8b7a9e6e0298366869e795ce3ddf0438af066e16
4
+ data.tar.gz: c2b834d39609a73bce6e5e6e19caf4d2499903498895d32fe22b55041db7e59b
5
5
  SHA512:
6
- metadata.gz: fce68faca7e1353f8d01bc2329fcb9d3157cdb38cedbdd75f451b2c2896d8119f2651b68eebcf8b5961b696c53f00f6a6a855d40fabfa9039d07e948a5a70683
7
- data.tar.gz: 3e9433a4e07bf24f0db174c87f2b8074cd51c1dacedfb5cf3bb403ed444f0403b81aa66ae9f3c2401f119a39edfebc53aba89246c3361719be7265cf0eabd5ca
6
+ metadata.gz: 24d9d44cb0f1f5ad7549b75067dd6cbc2eb2264b8cad1c1c8cf523c19d955e41b91feb86d6cde74104885fc61ee4c4f5309b3ed46edfc5751483ba282d5e841e
7
+ data.tar.gz: 1b1690d32daf8f44a19761d2ccc26a5a099d1daec623ecd481776f34ea439506278c267ff8b319c0ccb380ec497ca1de4b6e9c2a7eee677a1584d9d1cfe1ec87
data/.circleci/config.yml CHANGED
@@ -97,6 +97,8 @@ jobs:
97
97
  - run:
98
98
  name: run tests
99
99
  command: bundle exec rake test
100
+ - store_test_results:
101
+ path: test/reports
100
102
 
101
103
  build_artifacts:
102
104
  executor:
@@ -147,11 +149,11 @@ workflows:
147
149
  - test
148
150
  - publish_github:
149
151
  <<: *filters_publish
150
- context: Honeycomb Secrets
152
+ context: Honeycomb Secrets for Public Repos
151
153
  requires:
152
154
  - build_artifacts
153
155
  - publish_rubygems:
154
156
  <<: *filters_publish
155
- context: Honeycomb Secrets
157
+ context: Honeycomb Secrets for Public Repos
156
158
  requires:
157
159
  - build_artifacts
data/.github/CODEOWNERS CHANGED
@@ -2,4 +2,4 @@
2
2
  # This file controls who is tagged for review for any given pull request.
3
3
 
4
4
  # For anything not explicitly taken by someone else:
5
- * @honeycombio/integrations-team @martin308
5
+ * @honeycombio/telemetry-team
@@ -0,0 +1,15 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "bundler" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
12
+ labels:
13
+ - "type: dependencies"
14
+ reviewers:
15
+ - "honeycombio/telemetry-team"
@@ -0,0 +1,14 @@
1
+ name: Apply project management flow
2
+ on:
3
+ issues:
4
+ types: [opened]
5
+ pull_request_target:
6
+ types: [opened]
7
+ jobs:
8
+ project-management:
9
+ runs-on: ubuntu-latest
10
+ name: Apply project management flow
11
+ steps:
12
+ - uses: honeycombio/oss-management-actions/projects@v1
13
+ with:
14
+ ghprojects-token: ${{ secrets.GHPROJECTS_TOKEN }}
@@ -0,0 +1,10 @@
1
+ name: Apply project labels
2
+ on: [issues, pull_request, label]
3
+ jobs:
4
+ apply-labels:
5
+ runs-on: ubuntu-latest
6
+ name: Apply common project labels
7
+ steps:
8
+ - uses: honeycombio/oss-management-actions/labels@v1
9
+ with:
10
+ github-token: ${{ secrets.GITHUB_TOKEN }}
data/.gitignore CHANGED
@@ -7,6 +7,7 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /spec/examples.txt
10
+ /test/reports/
10
11
  /test/tmp/
11
12
  /test/version_tmp/
12
13
  /tmp/
data/.rubocop.yml CHANGED
@@ -9,17 +9,19 @@ Style/Documentation:
9
9
  Lint/RescueException:
10
10
  Exclude:
11
11
  - 'lib/libhoney/transmission.rb'
12
+ - 'lib/libhoney/experimental_transmission.rb'
12
13
 
13
14
  Metrics/BlockLength:
14
15
  Max: 35
15
16
 
16
17
  Metrics/ClassLength:
17
- Max: 200
18
+ Max: 300
18
19
  Exclude:
20
+ - lib/libhoney/transmission.rb # Should this remain so large?
19
21
  - test/*
20
22
 
21
23
  Metrics/MethodLength:
22
- Max: 40
24
+ Max: 45
23
25
  Exclude:
24
26
  - lib/libhoney/transmission.rb
25
27
  - test/*
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # libhoney-rb changelog
2
2
 
3
+ ## changes pending release
4
+
5
+ ## 1.19.0
6
+
7
+ ### Improvements
8
+
9
+ - add a test_helper, Minitest reporters, & store test results in CI (#88)
10
+ - add experimental transmission with new sized-and-timed queue (#87)
11
+
12
+ ### Fixes
13
+
14
+ - Process single-error responses from the Batch API (#89)
15
+
16
+ ## 1.18.0
17
+
18
+ ### Improvements
19
+
20
+ - replace HTTP client library to reduce external dependencies (#81)
21
+
22
+ ### Deprecations
23
+
24
+ - `Libhoney::Client.new(proxy_config: _)`: the `proxy_config` parameter for client
25
+ creation will no longer accept an Array in the next major version. The recommended
26
+ way to configure the client for operation behind forwarding web proxies is to set
27
+ http/https/no_proxy environment variables appropriately.
28
+
29
+ ## 1.17.0
30
+
31
+ ### Fixes:
32
+
33
+ - Allow Ruby 3.0.0 (removes overly-pessimistic exception) (#79)
34
+
35
+ ## 1.16.1
36
+
37
+ ### Fixes:
38
+
39
+ - Fix closing down the client when no threads have been started. (#74 & #76)
40
+
3
41
  ## 1.16.0
4
42
 
5
43
  ### Fixes:
@@ -1,6 +1,6 @@
1
+ require 'addressable/uri'
1
2
  require 'time'
2
3
  require 'json'
3
- require 'http'
4
4
  require 'forwardable'
5
5
 
6
6
  require 'libhoney/null_transmission'
@@ -46,11 +46,22 @@ module Libhoney
46
46
  # Honeycomb query engine will interpret it as representative of 10 events)
47
47
  # @param api_host [String] defaults to +API_HOST+, override to change the
48
48
  # destination for these Honeycomb events.
49
- # @param transmission [Object] transport used to actually send events. If nil (the default), will be lazily initialized with a {TransmissionClient} on first event send.
49
+ # @param transmission [Class, Object, nil] transport used to actually send events. If nil (the default),
50
+ # will be initialized with a {TransmissionClient}. If Class--for example, {MockTransmissionClient}--will attempt
51
+ # to create a new instance of that class with {TransmissionClient}'s usual parameters. If Object, checks
52
+ # that the instance passed in implements the Transmission interface (responds to :add and :close). If the
53
+ # check does not succeed, a no-op NullTransmission will be used instead.
50
54
  # @param block_on_send [Boolean] if more than pending_work_capacity events are written, block sending further events
51
55
  # @param block_on_responses [Boolean] if true, block if there is no thread reading from the response queue
52
56
  # @param pending_work_capacity [Fixnum] defaults to 1000. If the queue of
53
57
  # pending events exceeds 1000, this client will start dropping events.
58
+ # @param proxy_config [String, Array, nil] proxy connection information
59
+ # nil: (default, recommended) connection proxying will be determined from any http_proxy, https_proxy, and no_proxy environment
60
+ # variables set for the process.
61
+ # String: the value must be the URI for connecting to a forwarding web proxy. Must be parsable by stdlib URI.
62
+ # Array: (deprecated, removal in v2.0) the value must have one and at most four elements: e.g. ['host', port, 'username', 'password'].
63
+ # The assumption is that the TCP connection will be tunneled via HTTP, so the assumed scheme is 'http://'
64
+ # 'host' is required. 'port' is optional (default:80), unless a 'username' is included. 'password' is optional.
54
65
  # rubocop:disable Metrics/ParameterLists
55
66
  def initialize(writekey: nil,
56
67
  dataset: nil,
@@ -70,7 +81,7 @@ module Libhoney
70
81
  raise Exception, 'libhoney: max_concurrent_batches must be greater than 0' if max_concurrent_batches < 1
71
82
  raise Exception, 'libhoney: sample rate must be greater than 0' if sample_rate < 1
72
83
 
73
- unless Gem::Dependency.new('ruby', '~> 2.2').match?('ruby', RUBY_VERSION)
84
+ unless Gem::Dependency.new('ruby', '>= 2.2').match?('ruby', RUBY_VERSION)
74
85
  raise Exception, 'libhoney: Ruby versions < 2.2 are not supported'
75
86
  end
76
87
 
@@ -81,17 +92,6 @@ module Libhoney
81
92
  @builder.sample_rate = sample_rate
82
93
  @builder.api_host = api_host
83
94
 
84
- @transmission = transmission
85
- if !@transmission && !(writekey && dataset)
86
- # if no writekey or dataset are configured, and we didn't override the
87
- # transmission (e.g. to a MockTransmissionClient), that's almost
88
- # certainly a misconfiguration, even though it's possible to override
89
- # them on a per-event basis. So let's handle the misconfiguration
90
- # early rather than potentially throwing thousands of exceptions at runtime.
91
- warn "#{self.class.name}: no #{writekey ? 'dataset' : 'writekey'} configured, disabling sending events"
92
- @transmission = NullTransmissionClient.new
93
- end
94
-
95
95
  @user_agent_addition = user_agent_addition
96
96
 
97
97
  @block_on_send = block_on_send
@@ -101,8 +101,8 @@ module Libhoney
101
101
  @max_concurrent_batches = max_concurrent_batches
102
102
  @pending_work_capacity = pending_work_capacity
103
103
  @responses = SizedQueue.new(2 * @pending_work_capacity)
104
- @lock = Mutex.new
105
- @proxy_config = proxy_config
104
+ @proxy_config = parse_proxy_config(proxy_config)
105
+ @transmission = setup_transmission(transmission, writekey, dataset)
106
106
  end
107
107
 
108
108
  attr_reader :block_on_send, :block_on_responses, :max_batch_size,
@@ -190,22 +190,6 @@ module Libhoney
190
190
  # @param event [Event] the event to send to honeycomb
191
191
  # @api private
192
192
  def send_event(event)
193
- @lock.synchronize do
194
- transmission_client_params = {
195
- max_batch_size: @max_batch_size,
196
- send_frequency: @send_frequency,
197
- max_concurrent_batches: @max_concurrent_batches,
198
- pending_work_capacity: @pending_work_capacity,
199
- responses: @responses,
200
- block_on_send: @block_on_send,
201
- block_on_responses: @block_on_responses,
202
- user_agent_addition: @user_agent_addition,
203
- proxy_config: @proxy_config
204
- }
205
-
206
- @transmission ||= TransmissionClient.new(**transmission_client_params)
207
- end
208
-
209
193
  @transmission.add(event)
210
194
  end
211
195
 
@@ -224,5 +208,86 @@ module Libhoney
224
208
  def should_drop(sample_rate)
225
209
  rand(1..sample_rate) != 1
226
210
  end
211
+
212
+ private
213
+
214
+ ##
215
+ # Parameters to pass to a transmission based on client config.
216
+ #
217
+ def transmission_client_params
218
+ {
219
+ max_batch_size: @max_batch_size,
220
+ send_frequency: @send_frequency,
221
+ max_concurrent_batches: @max_concurrent_batches,
222
+ pending_work_capacity: @pending_work_capacity,
223
+ responses: @responses,
224
+ block_on_send: @block_on_send,
225
+ block_on_responses: @block_on_responses,
226
+ user_agent_addition: @user_agent_addition,
227
+ proxy_config: @proxy_config
228
+ }
229
+ end
230
+
231
+ def setup_transmission(transmission, writekey, dataset)
232
+ # if a provided transmission can add and close, we'll assume the user
233
+ # has provided a working transmission with a customized configuration
234
+ return transmission if quacks_like_a_transmission?(transmission)
235
+
236
+ if !(writekey && dataset) # rubocop:disable Style/NegatedIf
237
+ # if no writekey or dataset are configured, and we didn't override the
238
+ # transmission (e.g. to a MockTransmissionClient), that's almost
239
+ # certainly a misconfiguration, even though it's possible to override
240
+ # them on a per-event basis. So let's handle the misconfiguration
241
+ # early rather than potentially throwing thousands of exceptions at runtime.
242
+ warn "#{self.class.name}: no #{writekey ? 'dataset' : 'writekey'} configured, disabling sending events"
243
+ return NullTransmissionClient.new
244
+ end
245
+
246
+ case transmission
247
+ when NilClass # the default value for new clients
248
+ return TransmissionClient.new(**transmission_client_params)
249
+ when Class
250
+ # if a class has been provided, attempt to instantiate it with parameters given to the client
251
+ t = transmission.new(**transmission_client_params)
252
+ if quacks_like_a_transmission?(t) # rubocop:disable Style/GuardClause
253
+ return t
254
+ else
255
+ warn "#{t.class.name}: does not appear to behave like a transmission, disabling sending events"
256
+ return NullTransmissionClient.new
257
+ end
258
+ end
259
+ end
260
+
261
+ def quacks_like_a_transmission?(transmission)
262
+ transmission.respond_to?(:add) && transmission.respond_to?(:close)
263
+ end
264
+
265
+ # @api private
266
+ def parse_proxy_config(config)
267
+ case config
268
+ when nil then nil
269
+ when String
270
+ URI.parse(config)
271
+ when Array
272
+ warn <<-WARNING
273
+ DEPRECATION WARNING: #{self.class.name} the proxy_config parameter will require a String value, not an Array in libhoney 2.0.
274
+ To resolve:
275
+ + recommended: set http/https_proxy environment variables, which take precedence over any option set here, then remove proxy_config parameter from client initialization
276
+ + set proxy_config to a String containing the forwarding proxy URI (only used if http/https_proxy are not set)
277
+ WARNING
278
+ host, port, user, password = config
279
+
280
+ parsed_config = URI::HTTP.build(host: host, port: port).tap do |uri|
281
+ uri.userinfo = "#{user}:#{password}" if user
282
+ end
283
+ redacted_config = parsed_config.dup.tap do |uri|
284
+ uri.password = 'REDACTED' unless uri.password.nil? || uri.password.empty?
285
+ end
286
+ warn "The array config given has been assumed to mean: #{redacted_config}"
287
+ parsed_config
288
+ end
289
+ rescue URI::Error => e
290
+ warn "#{self.class.name}: unable to parse proxy_config. Detail: #{e.class}: #{e.message}"
291
+ end
227
292
  end
228
293
  end
@@ -0,0 +1,109 @@
1
+ require 'libhoney/queueing'
2
+ require 'libhoney/transmission'
3
+
4
+ module Libhoney
5
+ ##
6
+ # An experimental variant of the standard {TransmissionClient} that uses
7
+ # a custom implementation of a sized queue whose pop/push methods support
8
+ # a timeout internally.
9
+ #
10
+ # @example Use this transmission with the Ruby Beeline
11
+ # require 'libhoney/experimental_transmission'
12
+ #
13
+ # Honeycomb.configure do |config|
14
+ # config.client = Libhoney::Client.new(
15
+ # writekey: ENV["HONEYCOMB_WRITE_KEY"],
16
+ # dataset: ENV.fetch("HONEYCOMB_DATASET", "awesome_sauce"),
17
+ # transmission: Libhoney::ExperimentalTransmissionClient
18
+ # )
19
+ # ...
20
+ # end
21
+ #
22
+ # @api private
23
+ #
24
+ class ExperimentalTransmissionClient < TransmissionClient
25
+ def add(event)
26
+ return unless event_valid(event)
27
+
28
+ begin
29
+ # if block_on_send is true, never timeout the wait to enqueue an event
30
+ # otherwise, timeout the wait immediately and if the queue is full, we'll
31
+ # have a ThreadError raised because we could not add to the queue.
32
+ timeout = @block_on_send ? :never : 0
33
+ @batch_queue.enq(event, timeout)
34
+ rescue Libhoney::Queueing::SizedQueueWithTimeout::PushTimedOut
35
+ # happens if the queue was full and block_on_send = false.
36
+ warn "#{self.class.name}: batch queue full, dropping event." if %w[debug trace].include?(ENV['LOG_LEVEL'])
37
+ end
38
+
39
+ ensure_threads_running
40
+ end
41
+
42
+ def batch_loop
43
+ next_send_time = Time.now + @send_frequency
44
+ batched_events = Hash.new do |h, key|
45
+ h[key] = []
46
+ end
47
+
48
+ loop do
49
+ begin
50
+ # an event on the batch_queue
51
+ # 1. pops out and is truthy
52
+ # 2. gets included in the current batch
53
+ # 3. while waits for another event
54
+ while (event = @batch_queue.pop(@send_frequency))
55
+ key = [event.api_host, event.writekey, event.dataset]
56
+ batched_events[key] << event
57
+ end
58
+
59
+ # a nil on the batch_queue
60
+ # 1. pops out and is falsy
61
+ # 2. ends the event-popping while do..end
62
+ # 3. breaks the loop
63
+ # 4. flushes the current batch
64
+ # 5. ends the batch_loop
65
+ break
66
+
67
+ # a timeout expiration waiting for an event
68
+ # 1. skips the break and is rescued
69
+ # 2. triggers the ensure to flush the current batch
70
+ # 3. begins the loop again with an updated next_send_time
71
+ rescue Libhoney::Queueing::SizedQueueWithTimeout::PopTimedOut => e
72
+ warn "#{self.class.name}: ⏱ " + e.message if %w[trace].include?(ENV['LOG_LEVEL'])
73
+
74
+ # any exception occurring in this loop should not take down the actual
75
+ # instrumented Ruby process, so handle here and log that there is trouble
76
+ rescue Exception => e
77
+ warn "#{self.class.name}: 💥 " + e.message if %w[debug trace].include?(ENV['LOG_LEVEL'])
78
+ warn e.backtrace.join("\n").to_s if ['trace'].include?(ENV['LOG_LEVEL'])
79
+
80
+ # regardless of the exception, figure out whether enough time has passed to
81
+ # send the current batched events, if so, send them and figure out the next send time
82
+ # before going back to the top of the loop
83
+ ensure
84
+ next_send_time = flush_batched_events(batched_events) if Time.now > next_send_time
85
+ end
86
+ end
87
+
88
+ # don't need to capture the next_send_time here because the batch_loop is exiting
89
+ # for some reason (probably transmission.close)
90
+ flush_batched_events(batched_events)
91
+ end
92
+
93
+ private
94
+
95
+ def setup_batch_queue
96
+ # override super()'s @batch_queue = SizedQueue.new(); use our SizedQueueWithTimeout:
97
+ # + block on adding events to the batch_queue when queue is full and @block_on_send is true
98
+ # + the queue knows how to limit size and how to time-out pushes and pops
99
+ @batch_queue = Libhoney::Queueing::SizedQueueWithTimeout.new(@pending_work_capacity)
100
+ warn "⚠️🐆 #{self.class.name} in use! It may drop data, consume all your memory, or cause skin irritation."
101
+ end
102
+
103
+ def build_user_agent(user_agent_addition)
104
+ ua = "libhoney-rb/#{VERSION} (exp-transmission)"
105
+ ua << " #{user_agent_addition}" if user_agent_addition
106
+ ua
107
+ end
108
+ end
109
+ end
@@ -7,7 +7,7 @@ module Libhoney
7
7
  # verify what events your instrumented code is sending. Use in
8
8
  # production is not recommended.
9
9
  class MockTransmissionClient
10
- def initialize
10
+ def initialize(**_)
11
11
  reset
12
12
  end
13
13
 
@@ -4,6 +4,8 @@ module Libhoney
4
4
  #
5
5
  # @api private
6
6
  class NullTransmissionClient
7
+ def initialize(**_); end
8
+
7
9
  def add(event); end
8
10
 
9
11
  def close(drain); end
@@ -0,0 +1 @@
1
+ require 'libhoney/queueing/sized_queue_with_timeout'
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2021 Honeycomb
2
+ Copyright (c) 2013 Avdi Grimm
3
+
4
+ MIT License
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining
7
+ a copy of this software and associated documentation files (the
8
+ "Software"), to deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify, merge, publish,
10
+ distribute, sublicense, and/or sell copies of the Software, and to
11
+ permit persons to whom the Software is furnished to do so, subject to
12
+ the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
21
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
22
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
23
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,176 @@
1
+ ##
2
+ # SizedQueueWithTimeout is copyright and licensed per the LICENSE.txt in
3
+ # its containing subdirectory of this codebase.
4
+ #
5
+ module Libhoney
6
+ module Queueing
7
+ ##
8
+ # A queue implementation with optional size limit and optional timeouts on pop and push
9
+ # operations. Heavily influenced / liberally mimicking Avdi Grimm's
10
+ # {Tapas::Queue}[https://github.com/avdi/tapas-queue].
11
+ #
12
+ class SizedQueueWithTimeout
13
+ class PushTimedOut < ThreadError; end
14
+ class PopTimedOut < ThreadError; end
15
+
16
+ ##
17
+ # @param max_size [Integer, Float::INFINITY] the size limit for this queue
18
+ # @param options [Hash] optional dependencies to inject, primarily for testing
19
+ # @option options [QLock, Mutex] :lock the lock for synchronizing queue state change
20
+ # @option options [QCondition] :space_available_condition the condition variable
21
+ # to wait/signal on for space being available in the queue; when provided, must
22
+ # be accompanied by an +:item_available_condition+ and the shared +:lock+
23
+ # @option options [QCondition] :item_available_condition the condition variable
24
+ # to wait/signal on for an item being added to the queue; when provided, must
25
+ # be accompanied by an +:space_available_condition+ and the shared +:lock+
26
+ def initialize(max_size = Float::INFINITY, options = {})
27
+ @items = []
28
+ @max_size = max_size
29
+ @lock = options.fetch(:lock) { QLock.new }
30
+ @space_available = options.fetch(:space_available_condition) { QCondition.new(@lock) }
31
+ @item_available = options.fetch(:item_available_condition) { QCondition.new(@lock) }
32
+ end
33
+
34
+ ##
35
+ # Push something onto the queue.
36
+ #
37
+ # @param obj [Object] the thing to add to the queue
38
+ # @param timeout [Numeric, :never] how long in seconds to wait for the queue to have space available or
39
+ # +:never+ to wait "forever"
40
+ # @param timeout_policy [#call] defaults to +-> { raise PushTimedOut }+ - a lambda/Proc/callable, what to do
41
+ # when the timeout expires
42
+ #
43
+ # @raise {PushTimedOut}
44
+ def push(obj, timeout = :never, &timeout_policy)
45
+ timeout_policy ||= -> { raise PushTimedOut }
46
+
47
+ wait_for_condition(@space_available, -> { !full? }, timeout, timeout_policy) do
48
+ @items.push(obj)
49
+ @item_available.signal
50
+ end
51
+ end
52
+ alias enq push
53
+ alias << push
54
+
55
+ ##
56
+ # Pop something off the queue.
57
+ #
58
+ # @param timeout [Numeric, :never] how long in seconds to wait for the queue to have an item available or
59
+ # +:never+ to wait "forever"
60
+ # @param timeout_policy [#call] defaults to +-> { raise PopTimedOut }+ - a lambda/Proc/callable, what to do
61
+ # when the timeout expires
62
+ #
63
+ # @return [Object]
64
+ # @raise {PopTimedOut}
65
+ def pop(timeout = :never, &timeout_policy)
66
+ timeout_policy ||= -> { raise PopTimedOut }
67
+
68
+ wait_for_condition(@item_available, -> { !empty? }, timeout, timeout_policy) do
69
+ item = @items.shift
70
+ @space_available.signal unless full?
71
+ item
72
+ end
73
+ end
74
+ alias deq pop
75
+ alias shift pop
76
+
77
+ ##
78
+ # Removes all objects from the queue. They are cast into the abyss never to be seen again.
79
+ #
80
+ def clear
81
+ @lock.synchronize do
82
+ @items = []
83
+ @space_available.signal unless full?
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ ##
90
+ # Whether the queue is at capacity. Must be called with the queue's lock
91
+ # or the answer won't matter if you try to change state based on it.
92
+ #
93
+ # @return [true/false]
94
+ # @api private
95
+ def full?
96
+ @max_size <= @items.size
97
+ end
98
+
99
+ ##
100
+ # Whether the queue is empty. Must be called with the queue's lock or the
101
+ # answer won't matter if you try to change state based on it.
102
+ #
103
+ # @return [true/false]
104
+ # @api private
105
+ def empty?
106
+ @items.empty?
107
+ end
108
+
109
+ # a generic conditional variable wait with a timeout loop
110
+ #
111
+ # @param condition [#wait] a condition variable to wait upon.
112
+ # @param condition_predicate [#call] a callable (i.e. lambda or proc) that returns true/false to act
113
+ # as a state tester (i.e. "is the queue currently empty?") to check on whether to keep waiting;
114
+ # used to handle spurious wake ups occurring before the timeout has elapsed
115
+ # @param timeout [:never, Numeric] the amount of time in (seconds?) to wait, or :never to wait forever
116
+ # @param timeout_policy [#call] a callable, what to do when a timeout occurs? Return a default? Raise an
117
+ # exception? You decide.
118
+ def wait_for_condition(condition, condition_predicate, timeout = :never, timeout_policy = -> { nil })
119
+ deadline = timeout == :never ? :never : trustworthy_current_time + timeout
120
+ @lock.synchronize do
121
+ loop do
122
+ time_remaining = timeout == :never ? nil : deadline - trustworthy_current_time
123
+
124
+ if !condition_predicate.call && time_remaining.to_f >= 0 # rubocop:disable Style/IfUnlessModifier
125
+ condition.wait(time_remaining)
126
+ end
127
+
128
+ if condition_predicate.call # rubocop:disable Style/GuardClause
129
+ return yield
130
+ elsif deadline == :never || deadline > trustworthy_current_time
131
+ next
132
+ else
133
+ return timeout_policy.call
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ # Within the context of the current process, return time from a
140
+ # monotonically increasing clock because for timeouts we care about
141
+ # elapsed time within the process, not human time.
142
+ #
143
+ # @return [Numeric]
144
+ def trustworthy_current_time
145
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
+ end
147
+ end
148
+
149
+ class QCondition
150
+ def initialize(lock)
151
+ @lock = lock
152
+ @cv = ConditionVariable.new
153
+ end
154
+
155
+ def wait(timeout = nil)
156
+ @cv.wait(@lock.mutex, timeout)
157
+ end
158
+
159
+ def signal
160
+ @cv.signal
161
+ end
162
+ end
163
+
164
+ class QLock
165
+ attr_reader :mutex
166
+
167
+ def initialize
168
+ @mutex = Mutex.new
169
+ end
170
+
171
+ def synchronize(&block)
172
+ @mutex.synchronize(&block)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -1,7 +1,15 @@
1
- require 'http'
1
+ require 'http/response/status'
2
2
 
3
3
  module Libhoney
4
4
  class Response
5
+ # The response status from HTTP calls to a Honeycomb API endpoint.
6
+ #
7
+ # For most of the life of this client, this response object has been
8
+ # a pass-through to the underlying HTTP library's response object.
9
+ # This class in the Libhoney namespace now owns the interface for
10
+ # API responses.
11
+ class Status < HTTP::Response::Status; end
12
+
5
13
  attr_accessor :duration, :status_code, :metadata, :error
6
14
 
7
15
  def initialize(duration: 0,
@@ -9,7 +17,7 @@ module Libhoney
9
17
  metadata: nil,
10
18
  error: nil)
11
19
  @duration = duration
12
- @status_code = HTTP::Response::Status.new(status_code)
20
+ @status_code = Status.new(status_code)
13
21
  @metadata = metadata
14
22
  @error = error
15
23
  end
@@ -1,3 +1,5 @@
1
+ require 'addressable/uri'
2
+ require 'excon'
1
3
  require 'json'
2
4
  require 'timeout'
3
5
  require 'libhoney/response'
@@ -34,9 +36,9 @@ module Libhoney
34
36
  @send_queue = Queue.new
35
37
  @threads = []
36
38
  @lock = Mutex.new
37
- # use a SizedQueue so the producer will block on adding to the batch_queue when @block_on_send is true
38
- @batch_queue = SizedQueue.new(@pending_work_capacity)
39
39
  @batch_thread = nil
40
+
41
+ setup_batch_queue
40
42
  end
41
43
 
42
44
  def add(event)
@@ -51,26 +53,6 @@ module Libhoney
51
53
  ensure_threads_running
52
54
  end
53
55
 
54
- def event_valid(event)
55
- invalid = []
56
- invalid.push('api host') if event.api_host.nil? || event.api_host.empty?
57
- invalid.push('write key') if event.writekey.nil? || event.writekey.empty?
58
- invalid.push('dataset') if event.dataset.nil? || event.dataset.empty?
59
-
60
- unless invalid.empty?
61
- e = StandardError.new("#{self.class.name}: nil or empty required fields (#{invalid.join(', ')})"\
62
- '. Will not attempt to send.')
63
- Response.new(error: e).tap do |error_response|
64
- error_response.metadata = event.metadata
65
- enqueue_response(error_response)
66
- end
67
-
68
- return false
69
- end
70
-
71
- true
72
- end
73
-
74
56
  def send_loop
75
57
  http_clients = build_http_clients
76
58
 
@@ -93,7 +75,7 @@ module Libhoney
93
75
  }
94
76
 
95
77
  response = http.post(
96
- "/1/batch/#{Addressable::URI.escape(dataset)}",
78
+ path: "/1/batch/#{Addressable::URI.escape(dataset)}",
97
79
  body: body,
98
80
  headers: headers
99
81
  )
@@ -103,6 +85,8 @@ module Libhoney
103
85
  # because this is effectively the top-level exception handler for the
104
86
  # sender threads, and we don't want those threads to die (leaving
105
87
  # nothing consuming the queue).
88
+ warn "#{self.class.name}: 💥 " + e.message if %w[debug trace].include?(ENV['LOG_LEVEL'])
89
+ warn e.backtrace.join("\n").to_s if ['trace'].include?(ENV['LOG_LEVEL'])
106
90
  begin
107
91
  batch.each do |event|
108
92
  # nil events in the batch should already have had an error
@@ -129,23 +113,31 @@ module Libhoney
129
113
  end
130
114
 
131
115
  def close(drain)
132
- # if drain is false, clear the remaining unprocessed events from the queue
133
- unless drain
134
- @batch_queue.clear
135
- @send_queue.clear
136
- end
116
+ @lock.synchronize do
117
+ # if drain is false, clear the remaining unprocessed events from the queue
118
+ if drain
119
+ warn "#{self.class.name} - close: draining events" if %w[debug trace].include?(ENV['LOG_LEVEL'])
120
+ else
121
+ warn "#{self.class.name} - close: deleting unsent events" if %w[debug trace].include?(ENV['LOG_LEVEL'])
122
+ @batch_queue.clear
123
+ @send_queue.clear
124
+ end
137
125
 
138
- @batch_queue.enq(nil)
139
- @batch_thread.join
126
+ @batch_queue.enq(nil)
127
+ if @batch_thread.nil?
128
+ else
129
+ @batch_thread.join(1.0) # limit the amount of time we'll wait for the thread to end
130
+ end
140
131
 
141
- # send @threads.length number of nils so each thread will fall out of send_loop
142
- @threads.length.times { @send_queue << nil }
132
+ # send @threads.length number of nils so each thread will fall out of send_loop
133
+ @threads.length.times { @send_queue << nil }
143
134
 
144
- @threads.each(&:join)
145
- @threads = []
135
+ @threads.each(&:join)
136
+ @threads = []
137
+ end
146
138
 
147
139
  enqueue_response(nil)
148
-
140
+ warn "#{self.class.name} - close: close complete" if %w[debug trace].include?(ENV['LOG_LEVEL'])
149
141
  0
150
142
  end
151
143
 
@@ -157,25 +149,76 @@ module Libhoney
157
149
 
158
150
  loop do
159
151
  begin
152
+ # a timeout expiration waiting for an event
153
+ # 1. interrupts only when thread is in a blocking state (waiting for pop)
154
+ # 2. exception skips the break and is rescued
155
+ # 3. triggers the ensure to flush the current batch
156
+ # 3. begins the loop again with an updated next_send_time
160
157
  Thread.handle_interrupt(Timeout::Error => :on_blocking) do
158
+ # an event on the batch_queue
159
+ # 1. pops out and is truthy
160
+ # 2. gets included in the current batch
161
+ # 3. while waits for another event
161
162
  while (event = Timeout.timeout(@send_frequency) { @batch_queue.pop })
162
163
  key = [event.api_host, event.writekey, event.dataset]
163
164
  batched_events[key] << event
164
165
  end
165
166
  end
166
167
 
168
+ # a nil on the batch_queue
169
+ # 1. pops out and is falsy
170
+ # 2. ends the event-popping while do..end
171
+ # 3. breaks the loop
172
+ # 4. flushes the current batch
173
+ # 5. ends the batch_loop
167
174
  break
168
- rescue Exception
175
+ rescue Exception => e
176
+ warn "#{self.class.name}: 💥 " + e.message if %w[debug trace].include?(ENV['LOG_LEVEL'])
177
+ warn e.backtrace.join("\n").to_s if ['trace'].include?(ENV['LOG_LEVEL'])
178
+
179
+ # regardless of the exception, figure out whether enough time has passed to
180
+ # send the current batched events, if so, send them and figure out the next send time
181
+ # before going back to the top of the loop
169
182
  ensure
170
183
  next_send_time = flush_batched_events(batched_events) if Time.now > next_send_time
171
184
  end
172
185
  end
173
186
 
187
+ # don't need to capture the next_send_time here because the batch_loop is exiting
188
+ # for some reason (probably transmission.close)
174
189
  flush_batched_events(batched_events)
175
190
  end
176
191
 
177
192
  private
178
193
 
194
+ def setup_batch_queue
195
+ # use a SizedQueue so the producer will block on adding to the batch_queue when @block_on_send is true
196
+ @batch_queue = SizedQueue.new(@pending_work_capacity)
197
+ end
198
+
199
+ REQUIRED_EVENT_FIELDS = %i[api_host writekey dataset].freeze
200
+
201
+ def event_valid(event)
202
+ missing_required_fields = REQUIRED_EVENT_FIELDS.select do |required_field|
203
+ event.public_send(required_field).nil? || event.public_send(required_field).empty?
204
+ end
205
+
206
+ if missing_required_fields.empty?
207
+ true
208
+ else
209
+ enqueue_response(
210
+ Response.new(
211
+ metadata: event.metadata,
212
+ error: StandardError.new(
213
+ "#{self.class.name}: nil or empty required fields (#{missing_required_fields.join(', ')})"\
214
+ '. Will not attempt to send.'
215
+ )
216
+ )
217
+ )
218
+ false
219
+ end
220
+ end
221
+
179
222
  ##
180
223
  # Enqueues a response to the responses queue suppressing ThreadError when
181
224
  # there is no space left on the queue and we are not blocking on response
@@ -186,15 +229,36 @@ module Libhoney
186
229
  end
187
230
 
188
231
  def process_response(http_response, before, batch)
189
- index = 0
190
- http_response.parse.each do |event|
191
- index += 1 while batch[index].nil? && index < batch.size
192
- break unless (batched_event = batch[index])
193
-
194
- Response.new(status_code: event['status']).tap do |response|
195
- response.duration = Time.now - before
196
- response.metadata = batched_event.metadata
197
- enqueue_response(response)
232
+ if http_response.status == 200
233
+ index = 0
234
+ JSON.parse(http_response.body).each do |event|
235
+ index += 1 while batch[index].nil? && index < batch.size
236
+ break unless (batched_event = batch[index])
237
+
238
+ enqueue_response(
239
+ Response.new(
240
+ status_code: event['status'],
241
+ duration: (Time.now - before),
242
+ metadata: batched_event.metadata
243
+ )
244
+ )
245
+ end
246
+ else
247
+ error = JSON.parse(http_response.body)['error']
248
+ if %w[debug trace].include?(ENV['LOG_LEVEL'])
249
+ warn "#{self.class.name}: error sending data to Honeycomb - #{http_response.status} #{error}"
250
+ end
251
+ batch.each do |batched_event|
252
+ next unless batched_event # skip nils enqueued from serialization errors
253
+
254
+ enqueue_response(
255
+ Response.new(
256
+ status_code: http_response.status, # single error from API applied to all events sent in batch
257
+ duration: (Time.now - before),
258
+ metadata: batched_event.metadata,
259
+ error: RuntimeError.new(error)
260
+ )
261
+ )
198
262
  end
199
263
  end
200
264
  end
@@ -256,14 +320,19 @@ module Libhoney
256
320
 
257
321
  def build_http_clients
258
322
  Hash.new do |h, api_host|
259
- client = HTTP.timeout(connect: @send_timeout, write: @send_timeout, read: @send_timeout)
260
- .persistent(api_host)
261
- .headers(
262
- 'User-Agent' => @user_agent,
263
- 'Content-Type' => 'application/json'
264
- )
265
-
266
- client = client.via(*@proxy_config) unless @proxy_config.nil?
323
+ client = ::Excon.new(
324
+ api_host,
325
+ persistent: true,
326
+ read_timeout: @send_timeout,
327
+ write_timeout: @send_timeout,
328
+ connect_timeout: @send_timeout,
329
+ proxy: @proxy_config,
330
+ headers: {
331
+ 'User-Agent' => @user_agent,
332
+ 'Content-Type' => 'application/json'
333
+ }
334
+ )
335
+
267
336
  h[api_host] = client
268
337
  end
269
338
  end
@@ -1,3 +1,3 @@
1
1
  module Libhoney
2
- VERSION = '1.16.0'.freeze
2
+ VERSION = '1.19.0'.freeze
3
3
  end
data/libhoney.gemspec CHANGED
@@ -24,7 +24,9 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.add_development_dependency 'bump', '~> 0.5'
26
26
  spec.add_development_dependency 'bundler'
27
+ spec.add_development_dependency 'lockstep'
27
28
  spec.add_development_dependency 'minitest', '~> 5.0'
29
+ spec.add_development_dependency 'minitest-reporters'
28
30
  spec.add_development_dependency 'rake', '~> 12.3'
29
31
  spec.add_development_dependency 'rubocop', '< 0.69'
30
32
  spec.add_development_dependency 'sinatra'
@@ -34,5 +36,6 @@ Gem::Specification.new do |spec|
34
36
  spec.add_development_dependency 'yard'
35
37
  spec.add_development_dependency 'yardstick', '~> 0.9'
36
38
  spec.add_dependency 'addressable', '~> 2.0'
39
+ spec.add_dependency 'excon'
37
40
  spec.add_dependency 'http', '>= 2.0', '< 5.0'
38
41
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: libhoney
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.16.0
4
+ version: 1.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Honeycomb.io Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-11-25 00:00:00.000000000 Z
11
+ date: 2021-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bump
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: lockstep
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: minitest
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +66,20 @@ dependencies:
52
66
  - - "~>"
53
67
  - !ruby/object:Gem::Version
54
68
  version: '5.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest-reporters
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: rake
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -178,6 +206,20 @@ dependencies:
178
206
  - - "~>"
179
207
  - !ruby/object:Gem::Version
180
208
  version: '2.0'
209
+ - !ruby/object:Gem::Dependency
210
+ name: excon
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ type: :runtime
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
181
223
  - !ruby/object:Gem::Dependency
182
224
  name: http
183
225
  requirement: !ruby/object:Gem::Requirement
@@ -208,6 +250,9 @@ files:
208
250
  - ".circleci/setup-rubygems.sh"
209
251
  - ".editorconfig"
210
252
  - ".github/CODEOWNERS"
253
+ - ".github/dependabot.yml"
254
+ - ".github/workflows/add-to-project.yml"
255
+ - ".github/workflows/apply-labels.yml"
211
256
  - ".gitignore"
212
257
  - ".rubocop.yml"
213
258
  - ".rubocop_todo.yml"
@@ -224,11 +269,15 @@ files:
224
269
  - lib/libhoney/cleaner.rb
225
270
  - lib/libhoney/client.rb
226
271
  - lib/libhoney/event.rb
272
+ - lib/libhoney/experimental_transmission.rb
227
273
  - lib/libhoney/log_client.rb
228
274
  - lib/libhoney/log_transmission.rb
229
275
  - lib/libhoney/mock_transmission.rb
230
276
  - lib/libhoney/null_client.rb
231
277
  - lib/libhoney/null_transmission.rb
278
+ - lib/libhoney/queueing.rb
279
+ - lib/libhoney/queueing/LICENSE.txt
280
+ - lib/libhoney/queueing/sized_queue_with_timeout.rb
232
281
  - lib/libhoney/response.rb
233
282
  - lib/libhoney/test_client.rb
234
283
  - lib/libhoney/transmission.rb
@@ -253,7 +302,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
253
302
  - !ruby/object:Gem::Version
254
303
  version: '0'
255
304
  requirements: []
256
- rubygems_version: 3.1.4
305
+ rubygems_version: 3.1.6
257
306
  signing_key:
258
307
  specification_version: 4
259
308
  summary: send data to Honeycomb