txlemetry-ruby 4.0.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 +7 -0
- data/LICENSE +48 -0
- data/NOTICE +5 -0
- data/bin/txlemetry +110 -0
- data/lib/txlemetry/backoff_policy.rb +49 -0
- data/lib/txlemetry/client.rb +1266 -0
- data/lib/txlemetry/defaults.rb +50 -0
- data/lib/txlemetry/exception_capture.rb +246 -0
- data/lib/txlemetry/feature_flag.rb +85 -0
- data/lib/txlemetry/feature_flag_error.rb +37 -0
- data/lib/txlemetry/feature_flag_evaluations.rb +237 -0
- data/lib/txlemetry/feature_flag_result.rb +81 -0
- data/lib/txlemetry/feature_flags.rb +1516 -0
- data/lib/txlemetry/field_parser.rb +219 -0
- data/lib/txlemetry/flag_definition_cache.rb +88 -0
- data/lib/txlemetry/internal/context.rb +119 -0
- data/lib/txlemetry/logging.rb +77 -0
- data/lib/txlemetry/mcp/README.md +53 -0
- data/lib/txlemetry/mcp/analytics.rb +116 -0
- data/lib/txlemetry/mcp/client.rb +257 -0
- data/lib/txlemetry/mcp/constants.rb +111 -0
- data/lib/txlemetry/mcp/conversation_id.rb +89 -0
- data/lib/txlemetry/mcp/event_builder.rb +168 -0
- data/lib/txlemetry/mcp/exceptions.rb +112 -0
- data/lib/txlemetry/mcp/identity.rb +194 -0
- data/lib/txlemetry/mcp/ids.rb +56 -0
- data/lib/txlemetry/mcp/instrumentation.rb +736 -0
- data/lib/txlemetry/mcp/intent.rb +101 -0
- data/lib/txlemetry/mcp/log.rb +37 -0
- data/lib/txlemetry/mcp/options.rb +128 -0
- data/lib/txlemetry/mcp/rack_middleware.rb +112 -0
- data/lib/txlemetry/mcp/request_scope.rb +72 -0
- data/lib/txlemetry/mcp/sanitization.rb +504 -0
- data/lib/txlemetry/mcp/schema_mutation.rb +179 -0
- data/lib/txlemetry/mcp/server_extension.rb +54 -0
- data/lib/txlemetry/mcp/session.rb +71 -0
- data/lib/txlemetry/mcp/session_token.rb +106 -0
- data/lib/txlemetry/mcp/sink.rb +98 -0
- data/lib/txlemetry/mcp/tools.rb +91 -0
- data/lib/txlemetry/mcp/tracking_data.rb +89 -0
- data/lib/txlemetry/mcp/truncation.rb +326 -0
- data/lib/txlemetry/mcp.rb +224 -0
- data/lib/txlemetry/message_batch.rb +80 -0
- data/lib/txlemetry/noop_worker.rb +39 -0
- data/lib/txlemetry/response.rb +17 -0
- data/lib/txlemetry/send_feature_flags_options.rb +49 -0
- data/lib/txlemetry/send_worker.rb +218 -0
- data/lib/txlemetry/transport.rb +216 -0
- data/lib/txlemetry/utils.rb +183 -0
- data/lib/txlemetry/version.rb +5 -0
- data/lib/txlemetry.rb +16 -0
- metadata +105 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'txlemetry/utils'
|
|
4
|
+
|
|
5
|
+
module Txlemetry
|
|
6
|
+
# Options for configuring deprecated feature flag behavior in capture calls.
|
|
7
|
+
#
|
|
8
|
+
# @deprecated Prefer passing a {Txlemetry::FeatureFlagEvaluations} snapshot to `capture(flags:)`.
|
|
9
|
+
class SendFeatureFlagsOptions
|
|
10
|
+
# @return [Boolean, nil] Whether remote feature flag evaluation should be skipped.
|
|
11
|
+
attr_reader :only_evaluate_locally
|
|
12
|
+
|
|
13
|
+
# @return [Hash] Person properties to use for feature flag evaluation.
|
|
14
|
+
attr_reader :person_properties
|
|
15
|
+
|
|
16
|
+
# @return [Hash] Group properties to use for feature flag evaluation.
|
|
17
|
+
attr_reader :group_properties
|
|
18
|
+
|
|
19
|
+
# @param only_evaluate_locally [Boolean, nil] Skip remote feature flag evaluation.
|
|
20
|
+
# @param person_properties [Hash, nil] Person properties to use for feature flag evaluation.
|
|
21
|
+
# @param group_properties [Hash, nil] Group properties to use for feature flag evaluation.
|
|
22
|
+
def initialize(only_evaluate_locally: nil, person_properties: nil, group_properties: nil)
|
|
23
|
+
@only_evaluate_locally = only_evaluate_locally
|
|
24
|
+
@person_properties = person_properties || {}
|
|
25
|
+
@group_properties = group_properties || {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Hash] A hash representation suitable for `capture(send_feature_flags:)`.
|
|
29
|
+
def to_h
|
|
30
|
+
{
|
|
31
|
+
only_evaluate_locally: @only_evaluate_locally,
|
|
32
|
+
person_properties: @person_properties,
|
|
33
|
+
group_properties: @group_properties
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param hash [Hash]
|
|
38
|
+
# @return [Txlemetry::SendFeatureFlagsOptions, nil]
|
|
39
|
+
def self.from_hash(hash)
|
|
40
|
+
return nil unless hash.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
new(
|
|
43
|
+
only_evaluate_locally: Txlemetry::Utils.get_by_symbol_or_string_key(hash, :only_evaluate_locally),
|
|
44
|
+
person_properties: Txlemetry::Utils.get_by_symbol_or_string_key(hash, :person_properties),
|
|
45
|
+
group_properties: Txlemetry::Utils.get_by_symbol_or_string_key(hash, :group_properties)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'txlemetry/defaults'
|
|
4
|
+
require 'txlemetry/message_batch'
|
|
5
|
+
require 'txlemetry/transport'
|
|
6
|
+
require 'txlemetry/utils'
|
|
7
|
+
|
|
8
|
+
module Txlemetry
|
|
9
|
+
# Background worker that batches and sends queued events.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class SendWorker
|
|
13
|
+
include Txlemetry::Utils
|
|
14
|
+
include Txlemetry::Defaults
|
|
15
|
+
include Txlemetry::Logging
|
|
16
|
+
|
|
17
|
+
# public: Creates a new worker
|
|
18
|
+
#
|
|
19
|
+
# The worker continuously takes messages off the queue
|
|
20
|
+
# and makes requests to the txlemetry.com api
|
|
21
|
+
#
|
|
22
|
+
# @param queue [Queue] Queue synchronized between client and worker.
|
|
23
|
+
# @param api_key [String] Project API key.
|
|
24
|
+
# @param options [Hash] Worker options.
|
|
25
|
+
# @option options [Integer] :batch_size How many items to send in a batch.
|
|
26
|
+
# @option options [Numeric] :flush_interval_seconds Maximum seconds to wait for a batch to fill before sending.
|
|
27
|
+
# @option options [Proc] :on_error Callback invoked as `on_error.call(status, error)`.
|
|
28
|
+
# @option options [String] :host Txlemetry API host URL.
|
|
29
|
+
# @option options [Boolean] :skip_ssl_verification Disable SSL certificate verification.
|
|
30
|
+
# @option options [Boolean] :compress_request Set to +false+ to disable gzip batch request bodies.
|
|
31
|
+
def initialize(queue, api_key, options = {})
|
|
32
|
+
symbolize_keys! options
|
|
33
|
+
@queue = queue
|
|
34
|
+
@api_key = api_key
|
|
35
|
+
@on_error = options[:on_error] || proc { |status, error| }
|
|
36
|
+
batch_size = options[:batch_size] || Defaults::MessageBatch::MAX_SIZE
|
|
37
|
+
flush_interval_seconds = options[:flush_interval_seconds] || Defaults::MessageBatch::FLUSH_INTERVAL_SECONDS
|
|
38
|
+
@flush_interval_seconds = flush_interval_seconds.to_f
|
|
39
|
+
@batch = MessageBatch.new(batch_size)
|
|
40
|
+
@lock = Mutex.new
|
|
41
|
+
@state_lock = Mutex.new
|
|
42
|
+
@condition = ConditionVariable.new
|
|
43
|
+
@flush_requested = false
|
|
44
|
+
@shutdown = false
|
|
45
|
+
@pid = Process.pid
|
|
46
|
+
@transport_options = {
|
|
47
|
+
api_host: options[:host],
|
|
48
|
+
headers: options[:headers],
|
|
49
|
+
skip_ssl_verification: options[:skip_ssl_verification],
|
|
50
|
+
compress_request: options[:compress_request]
|
|
51
|
+
}
|
|
52
|
+
@transport_options[:retries] = options[:max_retries].to_i + 1 if options.key?(:max_retries)
|
|
53
|
+
@transport = Transport.new(@transport_options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Continuously runs the loop to check for new events.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def run
|
|
60
|
+
ensure_current_process!
|
|
61
|
+
|
|
62
|
+
until shutdown?
|
|
63
|
+
wait_for_work
|
|
64
|
+
break if shutdown?
|
|
65
|
+
next if @queue.empty?
|
|
66
|
+
|
|
67
|
+
build_batch
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
send_batch unless @batch.empty?
|
|
71
|
+
ensure
|
|
72
|
+
@lock.synchronize { @batch.clear }
|
|
73
|
+
clear_flush_request_if_idle
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
ensure
|
|
77
|
+
shutdown_transport
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Request the worker to send any pending events without waiting for the
|
|
81
|
+
# configured flush interval. Used by Client#flush and shutdown paths.
|
|
82
|
+
#
|
|
83
|
+
# @return [void]
|
|
84
|
+
def request_flush
|
|
85
|
+
ensure_current_process!
|
|
86
|
+
|
|
87
|
+
@state_lock.synchronize do
|
|
88
|
+
@flush_requested = true
|
|
89
|
+
@condition.broadcast
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Wake the worker when producers enqueue new messages.
|
|
94
|
+
#
|
|
95
|
+
# @return [void]
|
|
96
|
+
def notify
|
|
97
|
+
ensure_current_process!
|
|
98
|
+
|
|
99
|
+
@state_lock.synchronize { @condition.signal }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [void]
|
|
103
|
+
def shutdown
|
|
104
|
+
ensure_current_process!
|
|
105
|
+
|
|
106
|
+
@state_lock.synchronize do
|
|
107
|
+
@shutdown = true
|
|
108
|
+
@flush_requested = true
|
|
109
|
+
@condition.broadcast
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# public: Check whether we have outstanding requests.
|
|
114
|
+
#
|
|
115
|
+
# @return [Boolean] Whether the worker has outstanding requests.
|
|
116
|
+
# TODO: Rename to `requesting?` in future version
|
|
117
|
+
def is_requesting? # rubocop:disable Naming/PredicatePrefix
|
|
118
|
+
ensure_current_process!
|
|
119
|
+
|
|
120
|
+
@lock.synchronize { !@batch.empty? }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def ensure_current_process!
|
|
126
|
+
return if @pid == Process.pid
|
|
127
|
+
|
|
128
|
+
@lock.synchronize { @batch.clear }
|
|
129
|
+
@state_lock.synchronize do
|
|
130
|
+
@pid = Process.pid
|
|
131
|
+
@shutdown = false
|
|
132
|
+
@flush_requested = false
|
|
133
|
+
@condition.broadcast
|
|
134
|
+
end
|
|
135
|
+
shutdown_transport
|
|
136
|
+
@transport = Transport.new(@transport_options)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_batch
|
|
140
|
+
deadline = monotonic_time + @flush_interval_seconds
|
|
141
|
+
|
|
142
|
+
loop do
|
|
143
|
+
@lock.synchronize do
|
|
144
|
+
consume_message_from_queue! until @batch.full? || @queue.empty?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
break if @batch.full? || @batch.empty? || flush_requested?
|
|
148
|
+
|
|
149
|
+
remaining = deadline - monotonic_time
|
|
150
|
+
break unless remaining.positive?
|
|
151
|
+
|
|
152
|
+
wait_for_more_messages(remaining)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def send_batch
|
|
157
|
+
res = @transport.send @api_key, @batch
|
|
158
|
+
handle_error(res.status, res.error) unless res.status.between?(200, 299)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def consume_message_from_queue!
|
|
162
|
+
@batch << @queue.pop(true)
|
|
163
|
+
rescue ThreadError
|
|
164
|
+
# Queue was emptied by another thread between #empty? and #pop.
|
|
165
|
+
rescue MessageBatch::JSONGenerationError => e
|
|
166
|
+
handle_error(-1, e.to_s)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def wait_for_work
|
|
170
|
+
@state_lock.synchronize do
|
|
171
|
+
while @queue.empty? && !@shutdown
|
|
172
|
+
clear_flush_request_without_lock
|
|
173
|
+
@condition.wait(@state_lock)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def wait_for_more_messages(timeout)
|
|
179
|
+
@state_lock.synchronize do
|
|
180
|
+
return if @flush_requested || @shutdown || !@queue.empty?
|
|
181
|
+
|
|
182
|
+
@condition.wait(@state_lock, timeout)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def flush_requested?
|
|
187
|
+
@state_lock.synchronize { @flush_requested }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def shutdown?
|
|
191
|
+
@state_lock.synchronize { @shutdown }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def clear_flush_request
|
|
195
|
+
@state_lock.synchronize { clear_flush_request_without_lock }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def clear_flush_request_if_idle
|
|
199
|
+
@state_lock.synchronize { clear_flush_request_without_lock if @queue.empty? }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def clear_flush_request_without_lock
|
|
203
|
+
@flush_requested = false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def handle_error(status, error)
|
|
207
|
+
@on_error.call(status, error)
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
logger.error("Error in on_error callback: #{e.message}")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def shutdown_transport
|
|
213
|
+
@transport.shutdown
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
logger.error("Error shutting down transport: #{e.message}")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'txlemetry/defaults'
|
|
4
|
+
require 'txlemetry/utils'
|
|
5
|
+
require 'txlemetry/response'
|
|
6
|
+
require 'txlemetry/logging'
|
|
7
|
+
require 'txlemetry/backoff_policy'
|
|
8
|
+
require 'net/http'
|
|
9
|
+
require 'net/https'
|
|
10
|
+
require 'json'
|
|
11
|
+
require 'time'
|
|
12
|
+
require 'zlib'
|
|
13
|
+
|
|
14
|
+
module Txlemetry
|
|
15
|
+
# HTTP transport used by the SDK workers.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class Transport
|
|
19
|
+
include Txlemetry::Defaults::Request
|
|
20
|
+
include Txlemetry::Utils
|
|
21
|
+
include Txlemetry::Logging
|
|
22
|
+
|
|
23
|
+
# @param options [Hash] Transport configuration.
|
|
24
|
+
# @option options [String] :api_host Full Txlemetry API host URL.
|
|
25
|
+
# @option options [String] :host Hostname to connect to.
|
|
26
|
+
# @option options [Integer] :port Port to connect to.
|
|
27
|
+
# @option options [Boolean] :ssl Whether to use HTTPS.
|
|
28
|
+
# @option options [Hash] :headers HTTP headers for batch requests.
|
|
29
|
+
# @option options [String] :path HTTP path for batch requests.
|
|
30
|
+
# @option options [Integer] :retries Number of retry attempts for retryable failures.
|
|
31
|
+
# @option options [Txlemetry::BackoffPolicy] :backoff_policy Backoff policy used between retries.
|
|
32
|
+
# @option options [Boolean] :skip_ssl_verification Disable SSL certificate verification.
|
|
33
|
+
# @option options [Boolean] :compress_request Whether to gzip batch request bodies. Defaults to +true+.
|
|
34
|
+
def initialize(options = {})
|
|
35
|
+
if options[:api_host]
|
|
36
|
+
uri = URI.parse(options[:api_host])
|
|
37
|
+
options[:host] = uri.host
|
|
38
|
+
options[:ssl] = uri.scheme == 'https'
|
|
39
|
+
options[:port] = uri.port
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
options[:host] = options[:host].nil? ? HOST : options[:host]
|
|
43
|
+
options[:port] = options[:port].nil? ? PORT : options[:port]
|
|
44
|
+
options[:ssl] = options[:ssl].nil? ? SSL : options[:ssl]
|
|
45
|
+
|
|
46
|
+
@headers = (options[:headers] || HEADERS).dup
|
|
47
|
+
@path = options[:path] || PATH
|
|
48
|
+
@retries = options[:retries] || RETRIES
|
|
49
|
+
@backoff_policy = options[:backoff_policy] || Txlemetry::BackoffPolicy.new
|
|
50
|
+
@compress_request = options[:compress_request] != false
|
|
51
|
+
@last_retry_after = nil
|
|
52
|
+
|
|
53
|
+
http = Net::HTTP.new(options[:host], options[:port])
|
|
54
|
+
http.use_ssl = options[:ssl]
|
|
55
|
+
http.read_timeout = 8
|
|
56
|
+
http.open_timeout = 4
|
|
57
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if options[:skip_ssl_verification]
|
|
58
|
+
|
|
59
|
+
@http = http
|
|
60
|
+
@http_mutex = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Sends a batch of messages to the API
|
|
64
|
+
#
|
|
65
|
+
# @param api_key [String] Project API key.
|
|
66
|
+
# @param batch [Txlemetry::MessageBatch, Array<Hash>] Batch of messages to send.
|
|
67
|
+
# @return [Response] API response
|
|
68
|
+
def send(api_key, batch)
|
|
69
|
+
logger.debug("Sending request for #{batch.length} items")
|
|
70
|
+
|
|
71
|
+
last_response, exception =
|
|
72
|
+
retry_with_backoff(@retries) do
|
|
73
|
+
status_code, body = send_request(api_key, batch)
|
|
74
|
+
error =
|
|
75
|
+
begin
|
|
76
|
+
body && JSON.parse(body)['error']
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
body
|
|
79
|
+
end
|
|
80
|
+
should_retry = should_retry_request?(status_code, body)
|
|
81
|
+
logger.debug("Response status code: #{status_code}")
|
|
82
|
+
logger.debug("Response error: #{error}") if error
|
|
83
|
+
|
|
84
|
+
[Response.new(status_code, error), should_retry]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if exception
|
|
88
|
+
logger.error(exception.message)
|
|
89
|
+
exception.backtrace.each { |line| logger.error(line) }
|
|
90
|
+
Response.new(-1, exception.to_s)
|
|
91
|
+
else
|
|
92
|
+
last_response
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Closes a persistent connection if it exists.
|
|
97
|
+
#
|
|
98
|
+
# @return [void]
|
|
99
|
+
def shutdown
|
|
100
|
+
@http_mutex.synchronize do
|
|
101
|
+
@http.finish if @http.started?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def should_retry_request?(status_code, body)
|
|
108
|
+
if status_code >= 500 || [408, 429].include?(status_code)
|
|
109
|
+
true # Server error, request timeout, or rate limited
|
|
110
|
+
elsif status_code >= 400
|
|
111
|
+
logger.error(body)
|
|
112
|
+
false # Client error. Do not retry, but log
|
|
113
|
+
else
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Takes a block that returns [result, should_retry].
|
|
119
|
+
#
|
|
120
|
+
# Retries upto `retries_remaining` times, if `should_retry` is false or
|
|
121
|
+
# an exception is raised. `@backoff_policy` is used to determine the
|
|
122
|
+
# duration to sleep between attempts
|
|
123
|
+
#
|
|
124
|
+
# Returns [last_result, raised_exception]
|
|
125
|
+
def retry_with_backoff(retries_remaining, &block)
|
|
126
|
+
result, caught_exception = nil
|
|
127
|
+
should_retry = false
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
result, should_retry = yield
|
|
131
|
+
return result, nil unless should_retry
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
should_retry = true
|
|
134
|
+
caught_exception = e
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if should_retry && (retries_remaining > 1)
|
|
138
|
+
logger.debug("Retrying request, #{retries_remaining} retries left")
|
|
139
|
+
sleep(retry_delay_seconds)
|
|
140
|
+
retry_with_backoff(retries_remaining - 1, &block)
|
|
141
|
+
else
|
|
142
|
+
[result, caught_exception]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def retry_delay_seconds
|
|
147
|
+
retry_after = parse_retry_after(@last_retry_after)
|
|
148
|
+
@last_retry_after = nil
|
|
149
|
+
return retry_after if retry_after
|
|
150
|
+
|
|
151
|
+
@backoff_policy.next_interval.to_f / 1000
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_retry_after(value)
|
|
155
|
+
return nil if value.nil? || value.empty?
|
|
156
|
+
|
|
157
|
+
seconds = Float(value, exception: false)
|
|
158
|
+
return seconds if seconds && seconds >= 0
|
|
159
|
+
|
|
160
|
+
parsed_time = Time.httpdate(value)
|
|
161
|
+
delay = parsed_time - Time.now
|
|
162
|
+
delay.positive? ? delay : nil
|
|
163
|
+
rescue ArgumentError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Sends a request for the batch, returns [status_code, body]
|
|
168
|
+
def send_request(api_key, batch)
|
|
169
|
+
@last_retry_after = nil
|
|
170
|
+
payload = JSON.generate(api_key: api_key, batch: batch)
|
|
171
|
+
|
|
172
|
+
request_path, request_headers, request_payload = build_request(@path, @headers, payload)
|
|
173
|
+
request = Net::HTTP::Post.new(request_path, request_headers)
|
|
174
|
+
|
|
175
|
+
if self.class.stub
|
|
176
|
+
logger.debug "stubbed request to #{request_path}: " \
|
|
177
|
+
"api key = #{api_key}, batch = #{JSON.generate(batch)}"
|
|
178
|
+
|
|
179
|
+
[200, '{}']
|
|
180
|
+
else
|
|
181
|
+
@http_mutex.synchronize do
|
|
182
|
+
@http.start unless @http.started? # Maintain a persistent connection
|
|
183
|
+
response = @http.request(request, request_payload)
|
|
184
|
+
@last_retry_after = response['Retry-After']
|
|
185
|
+
[response.code.to_i, response.body]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_request(path, headers, payload)
|
|
191
|
+
return [path, headers, payload] unless @compress_request
|
|
192
|
+
|
|
193
|
+
compressed_payload = gzip_payload(payload)
|
|
194
|
+
return [path, headers, payload] unless compressed_payload
|
|
195
|
+
|
|
196
|
+
compressed_headers = headers.merge('Content-Encoding' => 'gzip')
|
|
197
|
+
|
|
198
|
+
[path, compressed_headers, compressed_payload]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def gzip_payload(payload)
|
|
202
|
+
Zlib.gzip(payload)
|
|
203
|
+
rescue Zlib::Error => e
|
|
204
|
+
logger.warn("gzip compression failed; sending uncompressed - #{e.message}")
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
class << self
|
|
209
|
+
attr_writer :stub
|
|
210
|
+
|
|
211
|
+
def stub
|
|
212
|
+
@stub || ENV.fetch('STUB', nil)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txlemetry
|
|
4
|
+
class InconclusiveMatchError < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
# Utility helpers used internally by the SDK.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Utils
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# public: Return a new hash with keys converted from strings to symbols
|
|
14
|
+
#
|
|
15
|
+
def symbolize_keys(hash)
|
|
16
|
+
hash.transform_keys(&:to_sym)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# public: Convert hash keys from strings to symbols in place
|
|
20
|
+
#
|
|
21
|
+
def symbolize_keys!(hash)
|
|
22
|
+
hash.replace symbolize_keys hash
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# public: Return a new hash with keys as strings
|
|
26
|
+
#
|
|
27
|
+
def stringify_keys(hash)
|
|
28
|
+
hash.transform_keys(&:to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# public: Recursively convert all keys to symbols in a Hash/Array tree
|
|
32
|
+
#
|
|
33
|
+
def deep_symbolize_keys(obj)
|
|
34
|
+
case obj
|
|
35
|
+
when Hash
|
|
36
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
37
|
+
result[key.to_sym] = deep_symbolize_keys(value)
|
|
38
|
+
end
|
|
39
|
+
when Array
|
|
40
|
+
obj.map { |item| deep_symbolize_keys(item) }
|
|
41
|
+
else
|
|
42
|
+
obj
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# public: Returns a new hash with all the date values in the into iso8601
|
|
47
|
+
# strings
|
|
48
|
+
#
|
|
49
|
+
def isoify_dates(hash)
|
|
50
|
+
hash.transform_values do |v|
|
|
51
|
+
datetime_in_iso8601(v)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# public: Converts all the date values in the into iso8601 strings in place
|
|
56
|
+
#
|
|
57
|
+
def isoify_dates!(hash)
|
|
58
|
+
hash.replace isoify_dates hash
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def datetime_in_iso8601(datetime)
|
|
62
|
+
case datetime
|
|
63
|
+
when Time
|
|
64
|
+
time_in_iso8601 datetime
|
|
65
|
+
when DateTime
|
|
66
|
+
time_in_iso8601 datetime.to_time
|
|
67
|
+
when Date
|
|
68
|
+
date_in_iso8601 datetime
|
|
69
|
+
else
|
|
70
|
+
datetime
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def time_in_iso8601(time, fraction_digits = 3)
|
|
75
|
+
fraction =
|
|
76
|
+
(('.%06i' % time.usec)[0, fraction_digits + 1] if fraction_digits.positive?) # rubocop:disable Style/FormatString
|
|
77
|
+
|
|
78
|
+
"#{time.strftime('%Y-%m-%dT%H:%M:%S')}#{fraction}#{formatted_offset(time, true, 'Z')}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def date_in_iso8601(date)
|
|
82
|
+
date.strftime('%F')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def formatted_offset(time, colon = true, alternate_utc_string = nil)
|
|
86
|
+
(time.utc? && alternate_utc_string) ||
|
|
87
|
+
seconds_to_utc_offset(time.utc_offset, colon)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def seconds_to_utc_offset(seconds, colon = true)
|
|
91
|
+
format((colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON), (seconds.negative? ? '-' : '+'),
|
|
92
|
+
seconds.abs / 3600, (seconds.abs % 3600) / 60)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def convert_to_datetime(value)
|
|
96
|
+
if value.respond_to?(:strftime)
|
|
97
|
+
value
|
|
98
|
+
|
|
99
|
+
elsif value.respond_to?(:to_str)
|
|
100
|
+
begin
|
|
101
|
+
DateTime.parse(value)
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
raise InconclusiveMatchError, "#{value} is not in a valid date format"
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
raise InconclusiveMatchError, 'The date provided must be a string or date object'
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
|
|
111
|
+
UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
|
|
112
|
+
|
|
113
|
+
# TODO: Rename to `valid_regex?` in future version
|
|
114
|
+
def is_valid_regex(regex) # rubocop:disable Naming/PredicatePrefix
|
|
115
|
+
Regexp.new(regex)
|
|
116
|
+
true
|
|
117
|
+
rescue RegexpError
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# public: Get value from hash by symbol key first, then string key
|
|
122
|
+
# Handles falsy values correctly (unlike ||)
|
|
123
|
+
#
|
|
124
|
+
# hash - Hash to lookup value in
|
|
125
|
+
# key - Symbol or String key to lookup
|
|
126
|
+
#
|
|
127
|
+
# Returns the value if found, nil otherwise
|
|
128
|
+
def get_by_symbol_or_string_key(hash, key)
|
|
129
|
+
symbol_key = key.to_sym
|
|
130
|
+
string_key = key.to_s
|
|
131
|
+
|
|
132
|
+
if hash.key?(symbol_key)
|
|
133
|
+
hash[symbol_key]
|
|
134
|
+
else
|
|
135
|
+
hash[string_key]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# public: Current monotonic clock time in seconds, for measuring durations
|
|
140
|
+
#
|
|
141
|
+
def monotonic_time
|
|
142
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Hash that clears itself when it reaches a maximum length.
|
|
146
|
+
#
|
|
147
|
+
# @api private
|
|
148
|
+
class SizeLimitedHash < Hash
|
|
149
|
+
# @param max_length [Integer]
|
|
150
|
+
def initialize(max_length, ...)
|
|
151
|
+
super(...)
|
|
152
|
+
@max_length = max_length
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @param key [Object]
|
|
156
|
+
# @param value [Object]
|
|
157
|
+
# @return [Object]
|
|
158
|
+
def []=(key, value)
|
|
159
|
+
clear if length >= @max_length
|
|
160
|
+
super
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Array that drops the oldest item when it reaches a maximum length.
|
|
165
|
+
#
|
|
166
|
+
# @api private
|
|
167
|
+
class SizeLimitedArray < Array
|
|
168
|
+
# @param max_length [Integer]
|
|
169
|
+
def initialize(max_length)
|
|
170
|
+
super()
|
|
171
|
+
@max_length = max_length
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param value [Object]
|
|
175
|
+
# @return [Array]
|
|
176
|
+
def <<(value)
|
|
177
|
+
shift if length >= @max_length
|
|
178
|
+
super
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
private_constant :SizeLimitedArray
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/txlemetry.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'txlemetry/version'
|
|
4
|
+
require 'txlemetry/defaults'
|
|
5
|
+
require 'txlemetry/utils'
|
|
6
|
+
require 'txlemetry/field_parser'
|
|
7
|
+
require 'txlemetry/client'
|
|
8
|
+
require 'txlemetry/send_worker'
|
|
9
|
+
require 'txlemetry/transport'
|
|
10
|
+
require 'txlemetry/response'
|
|
11
|
+
require 'txlemetry/logging'
|
|
12
|
+
require 'txlemetry/exception_capture'
|
|
13
|
+
require 'txlemetry/feature_flag_error'
|
|
14
|
+
require 'txlemetry/feature_flag_result'
|
|
15
|
+
require 'txlemetry/feature_flag_evaluations'
|
|
16
|
+
require 'txlemetry/flag_definition_cache'
|