libhoney 1.18.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -46,18 +46,19 @@ 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.
54
- # @param proxy_config [String, Array, nil] proxy connection information
58
+ # @param proxy_config [String, nil] proxy connection information
55
59
  # nil: (default, recommended) connection proxying will be determined from any http_proxy, https_proxy, and no_proxy environment
56
60
  # variables set for the process.
57
61
  # String: the value must be the URI for connecting to a forwarding web proxy. Must be parsable by stdlib URI.
58
- # Array: (deprecated, removal in v2.0) the value must have one and at most four elements: e.g. ['host', port, 'username', 'password'].
59
- # The assumption is that the TCP connection will be tunneled via HTTP, so the assumed scheme is 'http://'
60
- # 'host' is required. 'port' is optional (default:80), unless a 'username' is included. 'password' is optional.
61
62
  # rubocop:disable Metrics/ParameterLists
62
63
  def initialize(writekey: nil,
63
64
  dataset: nil,
@@ -74,12 +75,8 @@ module Libhoney
74
75
  proxy_config: nil)
75
76
  # rubocop:enable Metrics/ParameterLists
76
77
  # check for insanity
77
- raise Exception, 'libhoney: max_concurrent_batches must be greater than 0' if max_concurrent_batches < 1
78
- raise Exception, 'libhoney: sample rate must be greater than 0' if sample_rate < 1
79
-
80
- unless Gem::Dependency.new('ruby', '>= 2.2').match?('ruby', RUBY_VERSION)
81
- raise Exception, 'libhoney: Ruby versions < 2.2 are not supported'
82
- end
78
+ raise 'libhoney: max_concurrent_batches must be greater than 0' if max_concurrent_batches < 1
79
+ raise 'libhoney: sample rate must be greater than 0' if sample_rate < 1
83
80
 
84
81
  @builder = Builder.new(self, nil)
85
82
 
@@ -88,17 +85,6 @@ module Libhoney
88
85
  @builder.sample_rate = sample_rate
89
86
  @builder.api_host = api_host
90
87
 
91
- @transmission = transmission
92
- if !@transmission && !(writekey && dataset)
93
- # if no writekey or dataset are configured, and we didn't override the
94
- # transmission (e.g. to a MockTransmissionClient), that's almost
95
- # certainly a misconfiguration, even though it's possible to override
96
- # them on a per-event basis. So let's handle the misconfiguration
97
- # early rather than potentially throwing thousands of exceptions at runtime.
98
- warn "#{self.class.name}: no #{writekey ? 'dataset' : 'writekey'} configured, disabling sending events"
99
- @transmission = NullTransmissionClient.new
100
- end
101
-
102
88
  @user_agent_addition = user_agent_addition
103
89
 
104
90
  @block_on_send = block_on_send
@@ -108,8 +94,8 @@ module Libhoney
108
94
  @max_concurrent_batches = max_concurrent_batches
109
95
  @pending_work_capacity = pending_work_capacity
110
96
  @responses = SizedQueue.new(2 * @pending_work_capacity)
111
- @lock = Mutex.new
112
97
  @proxy_config = parse_proxy_config(proxy_config)
98
+ @transmission = setup_transmission(transmission, writekey, dataset)
113
99
  end
114
100
 
115
101
  attr_reader :block_on_send, :block_on_responses, :max_batch_size,
@@ -121,7 +107,7 @@ module Libhoney
121
107
 
122
108
  # Nuke the queue and wait for inflight requests to complete before returning.
123
109
  # If you set drain=false, all queued requests will be dropped on the floor.
124
- def close(drain = true)
110
+ def close(drain = true) # rubocop:disable Style/OptionalBooleanParameter
125
111
  return @transmission.close(drain) if @transmission
126
112
 
127
113
  0
@@ -197,22 +183,6 @@ module Libhoney
197
183
  # @param event [Event] the event to send to honeycomb
198
184
  # @api private
199
185
  def send_event(event)
200
- @lock.synchronize do
201
- transmission_client_params = {
202
- max_batch_size: @max_batch_size,
203
- send_frequency: @send_frequency,
204
- max_concurrent_batches: @max_concurrent_batches,
205
- pending_work_capacity: @pending_work_capacity,
206
- responses: @responses,
207
- block_on_send: @block_on_send,
208
- block_on_responses: @block_on_responses,
209
- user_agent_addition: @user_agent_addition,
210
- proxy_config: @proxy_config
211
- }
212
-
213
- @transmission ||= TransmissionClient.new(**transmission_client_params)
214
- end
215
-
216
186
  @transmission.add(event)
217
187
  end
218
188
 
@@ -234,6 +204,59 @@ module Libhoney
234
204
 
235
205
  private
236
206
 
207
+ ##
208
+ # Parameters to pass to a transmission based on client config.
209
+ #
210
+ def transmission_client_params
211
+ {
212
+ max_batch_size: @max_batch_size,
213
+ send_frequency: @send_frequency,
214
+ max_concurrent_batches: @max_concurrent_batches,
215
+ pending_work_capacity: @pending_work_capacity,
216
+ responses: @responses,
217
+ block_on_send: @block_on_send,
218
+ block_on_responses: @block_on_responses,
219
+ user_agent_addition: @user_agent_addition,
220
+ proxy_config: @proxy_config
221
+ }
222
+ end
223
+
224
+ def setup_transmission(transmission, writekey, dataset)
225
+ # if a provided transmission can add and close, we'll assume the user
226
+ # has provided a working transmission with a customized configuration
227
+ return transmission if quacks_like_a_transmission?(transmission)
228
+
229
+ if !(writekey && dataset) # rubocop:disable Style/NegatedIf
230
+ # if no writekey or dataset are configured, and we didn't override the
231
+ # transmission (e.g. to a MockTransmissionClient), that's almost
232
+ # certainly a misconfiguration, even though it's possible to override
233
+ # them on a per-event basis. So let's handle the misconfiguration
234
+ # early rather than potentially throwing thousands of exceptions at runtime.
235
+ warn "#{self.class.name}: no #{writekey ? 'dataset' : 'writekey'} configured, disabling sending events"
236
+ return NullTransmissionClient.new
237
+ end
238
+
239
+ case transmission
240
+ # rubocop:disable Style/GuardClause, Style/RedundantReturn
241
+ when NilClass # the default value for new clients
242
+ return TransmissionClient.new(**transmission_client_params)
243
+ when Class
244
+ # if a class has been provided, attempt to instantiate it with parameters given to the client
245
+ t = transmission.new(**transmission_client_params)
246
+ if quacks_like_a_transmission?(t)
247
+ return t
248
+ else
249
+ warn "#{t.class.name}: does not appear to behave like a transmission, disabling sending events"
250
+ return NullTransmissionClient.new
251
+ end
252
+ end
253
+ # rubocop:enable Style/GuardClause, Style/RedundantReturn
254
+ end
255
+
256
+ def quacks_like_a_transmission?(transmission)
257
+ transmission.respond_to?(:add) && transmission.respond_to?(:close)
258
+ end
259
+
237
260
  # @api private
238
261
  def parse_proxy_config(config)
239
262
  case config
@@ -241,22 +264,13 @@ module Libhoney
241
264
  when String
242
265
  URI.parse(config)
243
266
  when Array
244
- warn <<-WARNING
245
- DEPRECATION WARNING: #{self.class.name} the proxy_config parameter will require a String value, not an Array in libhoney 2.0.
246
- To resolve:
247
- + recommended: set http/https_proxy environment variables, which take precedence over any option set here, then remove proxy_config parameter from client initialization
248
- + set proxy_config to a String containing the forwarding proxy URI (only used if http/https_proxy are not set)
249
- WARNING
250
- host, port, user, password = config
251
-
252
- parsed_config = URI::HTTP.build(host: host, port: port).tap do |uri|
253
- uri.userinfo = "#{user}:#{password}" if user
254
- end
255
- redacted_config = parsed_config.dup.tap do |uri|
256
- uri.password = 'REDACTED' unless uri.password.nil? || uri.password.empty?
257
- end
258
- warn "The array config given has been assumed to mean: #{redacted_config}"
259
- parsed_config
267
+ error_message = <<~MESSAGE
268
+ #{self.class.name}: the optional proxy_config parameter requires a String value; Array is no longer supported.
269
+ To resolve:
270
+ + recommended: set http/https_proxy environment variables, which take precedence over any option set here, then remove proxy_config parameter from client initialization
271
+ + set proxy_config to a String containing the forwarding proxy URI (only used if http/https_proxy environment variables are not set)
272
+ MESSAGE
273
+ raise error_message
260
274
  end
261
275
  rescue URI::Error => e
262
276
  warn "#{self.class.name}: unable to parse proxy_config. Detail: #{e.class}: #{e.message}"
@@ -0,0 +1,107 @@
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
+ super("(exp-transmission) #{user_agent_addition}".strip)
105
+ end
106
+ end
107
+ end
@@ -10,7 +10,7 @@ module Libhoney
10
10
  # to verify what events your instrumented code is sending. Use in
11
11
  # production is not recommended.
12
12
  class LogClient < Client
13
- def initialize(*args, output: ::STDERR, verbose: false, **kwargs)
13
+ def initialize(*args, output: $stderr, verbose: false, **kwargs)
14
14
  super(*args,
15
15
  transmission: LogTransmissionClient.new(output: output, verbose: verbose),
16
16
  **kwargs)
@@ -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,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 = -> {})
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
@@ -0,0 +1 @@
1
+ require 'libhoney/queueing/sized_queue_with_timeout'