posthog-rails 3.5.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c928e33ae64462619dd81b5b785402c17f4cc5ebba9d7858cb33d7b38b426d4a
4
+ data.tar.gz: 5ee1084fde161538e21b53f257bc485aa9fafc0b6b4549776a28ab5d56c2de7a
5
+ SHA512:
6
+ metadata.gz: 3e3cc76336d84ab77d948e5a0504694926565bec21f8b535d70e5eb6671449b0676e60677f9257170b5e84c703e351baf9fe35ee091544df26859fdba7210f4c
7
+ data.tar.gz: 54d4dd5aee80aa5f85961672934cb7c565e16342327678f9352ed7ed6e9d484479418585ef4e9cbf39eed6e00063290c2a9e253f50621796f8e50774c97a5ade
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog/defaults'
4
+
5
+ module PostHog
6
+ class BackoffPolicy
7
+ include PostHog::Defaults::BackoffPolicy
8
+
9
+ # @param [Hash] opts
10
+ # @option opts [Numeric] :min_timeout_ms The minimum backoff timeout
11
+ # @option opts [Numeric] :max_timeout_ms The maximum backoff timeout
12
+ # @option opts [Numeric] :multiplier The value to multiply the current
13
+ # interval with for each retry attempt
14
+ # @option opts [Numeric] :randomization_factor The randomization factor
15
+ # to use to create a range around the retry interval
16
+ def initialize(opts = {})
17
+ @min_timeout_ms = opts[:min_timeout_ms] || MIN_TIMEOUT_MS
18
+ @max_timeout_ms = opts[:max_timeout_ms] || MAX_TIMEOUT_MS
19
+ @multiplier = opts[:multiplier] || MULTIPLIER
20
+ @randomization_factor =
21
+ opts[:randomization_factor] || RANDOMIZATION_FACTOR
22
+
23
+ @attempts = 0
24
+ end
25
+
26
+ # @return [Numeric] the next backoff interval, in milliseconds.
27
+ def next_interval
28
+ interval = @min_timeout_ms * (@multiplier**@attempts)
29
+ interval = add_jitter(interval, @randomization_factor)
30
+
31
+ @attempts += 1
32
+
33
+ [interval, @max_timeout_ms].min
34
+ end
35
+
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
+ random_number < 0.5 ? base - deviation : base + deviation
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,545 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'securerandom'
5
+
6
+ require 'posthog/defaults'
7
+ require 'posthog/logging'
8
+ require 'posthog/utils'
9
+ require 'posthog/send_worker'
10
+ require 'posthog/noop_worker'
11
+ require 'posthog/feature_flags'
12
+ require 'posthog/send_feature_flags_options'
13
+ require 'posthog/exception_capture'
14
+
15
+ module PostHog
16
+ class Client
17
+ include PostHog::Utils
18
+ include PostHog::Logging
19
+
20
+ # @param [Hash] opts
21
+ # @option opts [String] :api_key Your project's api_key
22
+ # @option opts [String] :personal_api_key Your personal API key
23
+ # @option opts [FixNum] :max_queue_size Maximum number of calls to be
24
+ # remain queued. Defaults to 10_000.
25
+ # @option opts [Bool] :test_mode +true+ if messages should remain
26
+ # queued for testing. Defaults to +false+.
27
+ # @option opts [Proc] :on_error Handles error calls from the API.
28
+ # @option opts [String] :host Fully qualified hostname of the PostHog server. Defaults to `https://app.posthog.com`
29
+ # @option opts [Integer] :feature_flags_polling_interval How often to poll for feature flag definition changes.
30
+ # Measured in seconds, defaults to 30.
31
+ # @option opts [Integer] :feature_flag_request_timeout_seconds How long to wait for feature flag evaluation.
32
+ # Measured in seconds, defaults to 3.
33
+ # @option opts [Proc] :before_send A block that receives the event hash and should return either a modified hash
34
+ # to be sent to PostHog or nil to prevent the event from being sent. e.g. `before_send: ->(event) { event }`
35
+ def initialize(opts = {})
36
+ symbolize_keys!(opts)
37
+
38
+ opts[:host] ||= 'https://app.posthog.com'
39
+
40
+ @queue = Queue.new
41
+ @api_key = opts[:api_key]
42
+ @max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
43
+ @worker_mutex = Mutex.new
44
+ @worker = if opts[:test_mode]
45
+ NoopWorker.new(@queue)
46
+ else
47
+ SendWorker.new(@queue, @api_key, opts)
48
+ end
49
+ @worker_thread = nil
50
+ @feature_flags_poller = nil
51
+ @personal_api_key = opts[:personal_api_key]
52
+
53
+ check_api_key!
54
+
55
+ @feature_flags_poller =
56
+ FeatureFlagsPoller.new(
57
+ opts[:feature_flags_polling_interval],
58
+ opts[:personal_api_key],
59
+ @api_key,
60
+ opts[:host],
61
+ opts[:feature_flag_request_timeout_seconds] || Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS,
62
+ opts[:on_error]
63
+ )
64
+
65
+ @distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) do |hash, key|
66
+ hash[key] = []
67
+ end
68
+
69
+ @before_send = opts[:before_send]
70
+ end
71
+
72
+ # Synchronously waits until the worker has cleared the queue.
73
+ #
74
+ # Use only for scripts which are not long-running, and will specifically
75
+ # exit
76
+ def flush
77
+ while !@queue.empty? || @worker.is_requesting?
78
+ ensure_worker_running
79
+ sleep(0.1)
80
+ end
81
+ end
82
+
83
+ # Clears the queue without waiting.
84
+ #
85
+ # Use only in test mode
86
+ def clear
87
+ @queue.clear
88
+ end
89
+
90
+ # @!macro common_attrs
91
+ # @option attrs [String] :message_id ID that uniquely
92
+ # identifies a message across the API. (optional)
93
+ # @option attrs [Time] :timestamp When the event occurred (optional)
94
+ # @option attrs [String] :distinct_id The ID for this user in your database
95
+
96
+ # Captures an event
97
+ #
98
+ # @param [Hash] attrs
99
+ #
100
+ # @option attrs [String] :event Event name
101
+ # @option attrs [Hash] :properties Event properties (optional)
102
+ # @option attrs [Bool, Hash, SendFeatureFlagsOptions] :send_feature_flags
103
+ # Whether to send feature flags with this event, or configuration for feature flag evaluation (optional)
104
+ # @option attrs [String] :uuid ID that uniquely identifies an event;
105
+ # events in PostHog are deduplicated by the
106
+ # combination of teamId, timestamp date,
107
+ # event name, distinct id, and UUID
108
+ # @macro common_attrs
109
+ def capture(attrs)
110
+ symbolize_keys! attrs
111
+
112
+ send_feature_flags_param = attrs[:send_feature_flags]
113
+ if send_feature_flags_param
114
+ # Handle different types of send_feature_flags parameter
115
+ case send_feature_flags_param
116
+ when true
117
+ # Backward compatibility: simple boolean
118
+ feature_variants = @feature_flags_poller.get_feature_variants(attrs[:distinct_id], attrs[:groups] || {})
119
+ when Hash
120
+ # Hash with options
121
+ options = SendFeatureFlagsOptions.from_hash(send_feature_flags_param)
122
+ feature_variants = @feature_flags_poller.get_feature_variants(
123
+ attrs[:distinct_id],
124
+ attrs[:groups] || {},
125
+ options ? options.person_properties : {},
126
+ options ? options.group_properties : {},
127
+ options ? options.only_evaluate_locally : false
128
+ )
129
+ when SendFeatureFlagsOptions
130
+ # SendFeatureFlagsOptions object
131
+ feature_variants = @feature_flags_poller.get_feature_variants(
132
+ attrs[:distinct_id],
133
+ attrs[:groups] || {},
134
+ send_feature_flags_param.person_properties,
135
+ send_feature_flags_param.group_properties,
136
+ send_feature_flags_param.only_evaluate_locally || false
137
+ )
138
+ else
139
+ # Invalid type, treat as false
140
+ feature_variants = nil
141
+ end
142
+
143
+ attrs[:feature_variants] = feature_variants if feature_variants
144
+ end
145
+
146
+ enqueue(FieldParser.parse_for_capture(attrs))
147
+ end
148
+
149
+ # Captures an exception as an event
150
+ #
151
+ # @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object
152
+ # @param [String] distinct_id The ID for the user (optional, defaults to a generated UUID)
153
+ # @param [Hash] additional_properties Additional properties to include with the exception event (optional)
154
+ def capture_exception(exception, distinct_id = nil, additional_properties = {})
155
+ exception_info = ExceptionCapture.build_parsed_exception(exception)
156
+
157
+ return if exception_info.nil?
158
+
159
+ no_distinct_id_was_provided = distinct_id.nil?
160
+ distinct_id ||= SecureRandom.uuid
161
+
162
+ properties = { '$exception_list' => [exception_info] }
163
+ properties.merge!(additional_properties) if additional_properties && !additional_properties.empty?
164
+ properties['$process_person_profile'] = false if no_distinct_id_was_provided
165
+
166
+ event_data = {
167
+ distinct_id: distinct_id,
168
+ event: '$exception',
169
+ properties: properties,
170
+ timestamp: Time.now
171
+ }
172
+
173
+ capture(event_data)
174
+ end
175
+
176
+ # Identifies a user
177
+ #
178
+ # @param [Hash] attrs
179
+ #
180
+ # @option attrs [Hash] :properties User properties (optional)
181
+ # @macro common_attrs
182
+ def identify(attrs)
183
+ symbolize_keys! attrs
184
+ enqueue(FieldParser.parse_for_identify(attrs))
185
+ end
186
+
187
+ # Identifies a group
188
+ #
189
+ # @param [Hash] attrs
190
+ #
191
+ # @option attrs [String] :group_type Group type
192
+ # @option attrs [String] :group_key Group key
193
+ # @option attrs [Hash] :properties Group properties (optional)
194
+ # @option attrs [String] :distinct_id Distinct ID (optional)
195
+ # @macro common_attrs
196
+ def group_identify(attrs)
197
+ symbolize_keys! attrs
198
+ enqueue(FieldParser.parse_for_group_identify(attrs))
199
+ end
200
+
201
+ # Aliases a user from one id to another
202
+ #
203
+ # @param [Hash] attrs
204
+ #
205
+ # @option attrs [String] :alias The alias to give the distinct id
206
+ # @macro common_attrs
207
+ def alias(attrs)
208
+ symbolize_keys! attrs
209
+ enqueue(FieldParser.parse_for_alias(attrs))
210
+ end
211
+
212
+ # @return [Hash] pops the last message from the queue
213
+ def dequeue_last_message
214
+ @queue.pop
215
+ end
216
+
217
+ # @return [Fixnum] number of messages in the queue
218
+ def queued_messages
219
+ @queue.length
220
+ end
221
+
222
+ # TODO: In future version, rename to `feature_flag_enabled?`
223
+ def is_feature_enabled( # rubocop:disable Naming/PredicateName
224
+ flag_key,
225
+ distinct_id,
226
+ groups: {},
227
+ person_properties: {},
228
+ group_properties: {},
229
+ only_evaluate_locally: false,
230
+ send_feature_flag_events: true
231
+ )
232
+ response = get_feature_flag(
233
+ flag_key,
234
+ distinct_id,
235
+ groups: groups,
236
+ person_properties: person_properties,
237
+ group_properties: group_properties,
238
+ only_evaluate_locally: only_evaluate_locally,
239
+ send_feature_flag_events: send_feature_flag_events
240
+ )
241
+ return nil if response.nil?
242
+
243
+ !!response
244
+ end
245
+
246
+ # @param [String] flag_key The unique flag key of the feature flag
247
+ # @return [String] The decrypted value of the feature flag payload
248
+ def get_remote_config_payload(flag_key)
249
+ @feature_flags_poller.get_remote_config_payload(flag_key)
250
+ end
251
+
252
+ # Returns whether the given feature flag is enabled for the given user or not
253
+ #
254
+ # @param [String] key The key of the feature flag
255
+ # @param [String] distinct_id The distinct id of the user
256
+ # @param [Hash] groups
257
+ # @param [Hash] person_properties key-value pairs of properties to associate with the user.
258
+ # @param [Hash] group_properties
259
+ #
260
+ # @return [String, nil] The value of the feature flag
261
+ #
262
+ # The provided properties are used to calculate feature flags locally, if possible.
263
+ #
264
+ # `groups` are a mapping from group type to group key. So, if you have a group type of "organization"
265
+ # and a group key of "5",
266
+ # you would pass groups={"organization": "5"}.
267
+ # `group_properties` take the format: { group_type_name: { group_properties } }
268
+ # So, for example, if you have the group type "organization" and the group key "5", with the properties name,
269
+ # and employee count, you'll send these as:
270
+ # ```ruby
271
+ # group_properties: {"organization": {"name": "PostHog", "employees": 11}}
272
+ # ```
273
+ def get_feature_flag(
274
+ key,
275
+ distinct_id,
276
+ groups: {},
277
+ person_properties: {},
278
+ group_properties: {},
279
+ only_evaluate_locally: false,
280
+ send_feature_flag_events: true
281
+ )
282
+ result = get_feature_flag_result(
283
+ key,
284
+ distinct_id,
285
+ groups: groups,
286
+ person_properties: person_properties,
287
+ group_properties: group_properties,
288
+ only_evaluate_locally: only_evaluate_locally,
289
+ send_feature_flag_events: send_feature_flag_events
290
+ )
291
+ result&.value
292
+ end
293
+
294
+ # Returns both the feature flag value and payload in a single call.
295
+ # This method raises the $feature_flag_called event with the payload included.
296
+ #
297
+ # @param [String] key The key of the feature flag
298
+ # @param [String] distinct_id The distinct id of the user
299
+ # @param [Hash] groups
300
+ # @param [Hash] person_properties key-value pairs of properties to associate with the user.
301
+ # @param [Hash] group_properties
302
+ # @param [Boolean] only_evaluate_locally
303
+ # @param [Boolean] send_feature_flag_events
304
+ #
305
+ # @return [FeatureFlagResult, nil] A FeatureFlagResult object containing the flag value and payload,
306
+ # or nil if the flag evaluation returned nil
307
+ def get_feature_flag_result(
308
+ key,
309
+ distinct_id,
310
+ groups: {},
311
+ person_properties: {},
312
+ group_properties: {},
313
+ only_evaluate_locally: false,
314
+ send_feature_flag_events: true
315
+ )
316
+ person_properties, group_properties = add_local_person_and_group_properties(
317
+ distinct_id,
318
+ groups,
319
+ person_properties,
320
+ group_properties
321
+ )
322
+ feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload =
323
+ @feature_flags_poller.get_feature_flag(
324
+ key,
325
+ distinct_id,
326
+ groups,
327
+ person_properties,
328
+ group_properties,
329
+ only_evaluate_locally
330
+ )
331
+ feature_flag_reported_key = "#{key}_#{feature_flag_response}"
332
+
333
+ if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
334
+ properties = {
335
+ '$feature_flag' => key,
336
+ '$feature_flag_response' => feature_flag_response,
337
+ 'locally_evaluated' => flag_was_locally_evaluated
338
+ }
339
+ properties['$feature_flag_request_id'] = request_id if request_id
340
+ properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
341
+ properties['$feature_flag_error'] = feature_flag_error if feature_flag_error
342
+
343
+ capture(
344
+ distinct_id: distinct_id,
345
+ event: '$feature_flag_called',
346
+ properties: properties,
347
+ groups: groups
348
+ )
349
+ @distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key
350
+ end
351
+
352
+ FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload)
353
+ end
354
+
355
+ # Returns all flags for a given user
356
+ #
357
+ # @param [String] distinct_id The distinct id of the user
358
+ # @param [Hash] groups
359
+ # @param [Hash] person_properties key-value pairs of properties to associate with the user.
360
+ # @param [Hash] group_properties
361
+ #
362
+ # @return [Hash] String (not symbol) key value pairs of flag and their values
363
+ def get_all_flags(
364
+ distinct_id,
365
+ groups: {},
366
+ person_properties: {},
367
+ group_properties: {},
368
+ only_evaluate_locally: false
369
+ )
370
+ person_properties, group_properties = add_local_person_and_group_properties(distinct_id, groups,
371
+ person_properties, group_properties)
372
+ @feature_flags_poller.get_all_flags(distinct_id, groups, person_properties, group_properties,
373
+ only_evaluate_locally)
374
+ end
375
+
376
+ # Returns payload for a given feature flag
377
+ #
378
+ # @deprecated Use {#get_feature_flag_result} instead, which returns both the flag value and payload
379
+ # and properly raises the $feature_flag_called event.
380
+ #
381
+ # @param [String] key The key of the feature flag
382
+ # @param [String] distinct_id The distinct id of the user
383
+ # @option [String or boolean] match_value The value of the feature flag to be matched
384
+ # @option [Hash] groups
385
+ # @option [Hash] person_properties key-value pairs of properties to associate with the user.
386
+ # @option [Hash] group_properties
387
+ # @option [Boolean] only_evaluate_locally
388
+ #
389
+ def get_feature_flag_payload(
390
+ key,
391
+ distinct_id,
392
+ match_value: nil,
393
+ groups: {},
394
+ person_properties: {},
395
+ group_properties: {},
396
+ only_evaluate_locally: false
397
+ )
398
+ person_properties, group_properties = add_local_person_and_group_properties(distinct_id, groups,
399
+ person_properties, group_properties)
400
+ @feature_flags_poller.get_feature_flag_payload(key, distinct_id, match_value, groups, person_properties,
401
+ group_properties, only_evaluate_locally)
402
+ end
403
+
404
+ # Returns all flags and payloads for a given user
405
+ #
406
+ # @return [Hash] A hash with the following keys:
407
+ # featureFlags: A hash of feature flags
408
+ # featureFlagPayloads: A hash of feature flag payloads
409
+ #
410
+ # @param [String] distinct_id The distinct id of the user
411
+ # @option [Hash] groups
412
+ # @option [Hash] person_properties key-value pairs of properties to associate with the user.
413
+ # @option [Hash] group_properties
414
+ # @option [Boolean] only_evaluate_locally
415
+ #
416
+ def get_all_flags_and_payloads(
417
+ distinct_id,
418
+ groups: {},
419
+ person_properties: {},
420
+ group_properties: {},
421
+ only_evaluate_locally: false
422
+ )
423
+ person_properties, group_properties = add_local_person_and_group_properties(
424
+ distinct_id, groups, person_properties, group_properties
425
+ )
426
+ response = @feature_flags_poller.get_all_flags_and_payloads(
427
+ distinct_id, groups, person_properties, group_properties, only_evaluate_locally
428
+ )
429
+
430
+ # Remove internal information
431
+ response.delete(:requestId)
432
+ response.delete(:evaluatedAt)
433
+ response
434
+ end
435
+
436
+ def reload_feature_flags
437
+ unless @personal_api_key
438
+ logger.error(
439
+ 'You need to specify a personal_api_key to locally evaluate feature flags'
440
+ )
441
+ return
442
+ end
443
+ @feature_flags_poller.load_feature_flags(true)
444
+ end
445
+
446
+ def shutdown
447
+ @feature_flags_poller.shutdown_poller
448
+ flush
449
+ end
450
+
451
+ private
452
+
453
+ # before_send should run immediately before the event is sent to the queue.
454
+ # @param [Object] action The event to be sent to PostHog
455
+ # @return [null, Object, nil] The processed event or nil if the event should not be sent
456
+ def process_before_send(action)
457
+ return action if action.nil? || action.empty?
458
+ return action unless @before_send
459
+
460
+ begin
461
+ processed_action = @before_send.call(action)
462
+
463
+ if processed_action.nil?
464
+ logger.warn("Event #{action[:event]} was rejected in beforeSend function")
465
+ elsif processed_action.empty?
466
+ logger.warn("Event #{action[:event]} has no properties after beforeSend function, this is likely an error")
467
+ end
468
+
469
+ processed_action
470
+ rescue StandardError => e
471
+ logger.error("Error in beforeSend function - using original event: #{e.message}")
472
+ action
473
+ end
474
+ end
475
+
476
+ # private: Enqueues the action.
477
+ #
478
+ # returns Boolean of whether the item was added to the queue.
479
+ def enqueue(action)
480
+ action = process_before_send(action)
481
+ return false if action.nil? || action.empty?
482
+
483
+ # add our request id for tracing purposes
484
+ action[:messageId] ||= uid
485
+
486
+ if @queue.length < @max_queue_size
487
+ @queue << action
488
+ ensure_worker_running
489
+
490
+ true
491
+ else
492
+ logger.warn(
493
+ 'Queue is full, dropping events. The :max_queue_size ' \
494
+ 'configuration parameter can be increased to prevent this from ' \
495
+ 'happening.'
496
+ )
497
+ false
498
+ end
499
+ end
500
+
501
+ # private: Checks that the api_key is properly initialized
502
+ def check_api_key!
503
+ raise ArgumentError, 'API key must be initialized' if @api_key.nil?
504
+ end
505
+
506
+ def ensure_worker_running
507
+ return if worker_running?
508
+
509
+ @worker_mutex.synchronize do
510
+ return if worker_running?
511
+
512
+ @worker_thread = Thread.new { @worker.run }
513
+ end
514
+ end
515
+
516
+ def worker_running?
517
+ @worker_thread&.alive?
518
+ end
519
+
520
+ def add_local_person_and_group_properties(distinct_id, groups, person_properties, group_properties)
521
+ groups ||= {}
522
+ person_properties ||= {}
523
+ group_properties ||= {}
524
+
525
+ symbolize_keys! groups
526
+ symbolize_keys! person_properties
527
+ symbolize_keys! group_properties
528
+
529
+ group_properties.each_value do |value|
530
+ symbolize_keys! value
531
+ end
532
+
533
+ all_person_properties = { distinct_id: distinct_id }.merge(person_properties)
534
+
535
+ all_group_properties = {}
536
+ groups&.each do |group_name, group_key|
537
+ all_group_properties[group_name] = {
538
+ '$group_key': group_key
539
+ }.merge((group_properties && group_properties[group_name]) || {})
540
+ end
541
+
542
+ [all_person_properties, all_group_properties]
543
+ end
544
+ end
545
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module Defaults
5
+ MAX_HASH_SIZE = 50_000
6
+
7
+ module Request
8
+ HOST = 'app.posthog.com'
9
+ PORT = 443
10
+ PATH = '/batch/'
11
+ SSL = true
12
+ HEADERS = {
13
+ 'Accept' => 'application/json',
14
+ 'Content-Type' => 'application/json',
15
+ 'User-Agent' => "posthog-ruby/#{PostHog::VERSION}"
16
+ }.freeze
17
+ RETRIES = 10
18
+ end
19
+
20
+ module FeatureFlags
21
+ FLAG_REQUEST_TIMEOUT_SECONDS = 3
22
+ end
23
+
24
+ module Queue
25
+ MAX_SIZE = 10_000
26
+ end
27
+
28
+ module Message
29
+ MAX_BYTES = 32_768 # 32Kb
30
+ end
31
+
32
+ module MessageBatch
33
+ MAX_BYTES = 512_000 # 500Kb
34
+ MAX_SIZE = 100
35
+ end
36
+
37
+ module BackoffPolicy
38
+ MIN_TIMEOUT_MS = 100
39
+ MAX_TIMEOUT_MS = 10_000
40
+ MULTIPLIER = 1.5
41
+ RANDOMIZATION_FACTOR = 0.5
42
+ end
43
+ end
44
+ end