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,1266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
require 'txlemetry/defaults'
|
|
8
|
+
require 'txlemetry/logging'
|
|
9
|
+
require 'txlemetry/utils'
|
|
10
|
+
require 'txlemetry/send_worker'
|
|
11
|
+
require 'txlemetry/noop_worker'
|
|
12
|
+
require 'txlemetry/message_batch'
|
|
13
|
+
require 'txlemetry/transport'
|
|
14
|
+
require 'txlemetry/feature_flags'
|
|
15
|
+
require 'txlemetry/feature_flag_evaluations'
|
|
16
|
+
require 'txlemetry/send_feature_flags_options'
|
|
17
|
+
require 'txlemetry/exception_capture'
|
|
18
|
+
require 'txlemetry/internal/context'
|
|
19
|
+
|
|
20
|
+
module Txlemetry
|
|
21
|
+
class Client
|
|
22
|
+
include Txlemetry::Utils
|
|
23
|
+
include Txlemetry::Logging
|
|
24
|
+
|
|
25
|
+
# Strict allowlist of properties kept on minimal `$feature_flag_called`
|
|
26
|
+
# events (server-gated, non-experiment flags only). Everything else —
|
|
27
|
+
# context properties, `$feature/<key>`, payloads, system metadata — is
|
|
28
|
+
# stripped. Includes symbol forms so allowlisted properties supplied
|
|
29
|
+
# through context with symbol keys survive.
|
|
30
|
+
MINIMAL_FLAG_CALLED_EVENT_PROPERTIES = %w[
|
|
31
|
+
$feature_flag
|
|
32
|
+
$feature_flag_response
|
|
33
|
+
$feature_flag_has_experiment
|
|
34
|
+
$feature_flag_id
|
|
35
|
+
$feature_flag_version
|
|
36
|
+
$feature_flag_reason
|
|
37
|
+
$feature_flag_request_id
|
|
38
|
+
$feature_flag_evaluated_at
|
|
39
|
+
$feature_flag_error
|
|
40
|
+
locally_evaluated
|
|
41
|
+
$groups
|
|
42
|
+
$process_person_profile
|
|
43
|
+
$session_id
|
|
44
|
+
$is_server
|
|
45
|
+
$lib
|
|
46
|
+
$lib_version
|
|
47
|
+
$release_id
|
|
48
|
+
].flat_map { |key| [key, key.to_sym] }.freeze
|
|
49
|
+
private_constant :MINIMAL_FLAG_CALLED_EVENT_PROPERTIES
|
|
50
|
+
|
|
51
|
+
# Holds the id that `txlemetry-cli release resolve` prints. Error tracking
|
|
52
|
+
# links an exception to its release by this id, because a Ruby app has no
|
|
53
|
+
# build step that could inject it.
|
|
54
|
+
RELEASE_ID_ENV_VAR = 'TXLEMETRY_RELEASE_ID'
|
|
55
|
+
private_constant :RELEASE_ID_ENV_VAR
|
|
56
|
+
|
|
57
|
+
# Thread-safe tracking of client instances per API key for singleton warnings
|
|
58
|
+
@instances_by_api_key = {}
|
|
59
|
+
@instances_mutex = Mutex.new
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
# Resets instance tracking. Used primarily for testing.
|
|
63
|
+
# In production, instance counts persist for the lifetime of the process.
|
|
64
|
+
def reset_instance_tracking!
|
|
65
|
+
@instances_mutex.synchronize do
|
|
66
|
+
@instances_by_api_key = {}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def _increment_instance_count(api_key)
|
|
71
|
+
@instances_mutex.synchronize do
|
|
72
|
+
count = @instances_by_api_key[api_key] || 0
|
|
73
|
+
@instances_by_api_key[api_key] = count + 1
|
|
74
|
+
count
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def _decrement_instance_count(api_key)
|
|
79
|
+
@instances_mutex.synchronize do
|
|
80
|
+
count = (@instances_by_api_key[api_key] || 1) - 1
|
|
81
|
+
@instances_by_api_key[api_key] = [count, 0].max
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param opts [Hash] Client configuration.
|
|
87
|
+
# @option opts [String, nil] :api_key Your project's API key. Missing or blank values disable the client.
|
|
88
|
+
# @option opts [String, nil] :secret_key The credential used for local feature flag evaluation and remote
|
|
89
|
+
# config. Accepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`). Required
|
|
90
|
+
# for local feature flag evaluation.
|
|
91
|
+
# @option opts [String, nil] :personal_api_key
|
|
92
|
+
# @deprecated Use +:secret_key+ instead. Retained as an alias; when both are supplied, +:secret_key+ wins.
|
|
93
|
+
# @option opts [String] :host Fully qualified hostname of the Txlemetry server. Defaults to `https://app.txlemetry.com`.
|
|
94
|
+
# @option opts [Integer] :max_queue_size Maximum number of calls to remain queued. Defaults to 10_000.
|
|
95
|
+
# @option opts [Integer] :batch_size Maximum number of events to send in one async batch.
|
|
96
|
+
# @option opts [Numeric] :flush_interval_seconds Maximum seconds to wait for an async batch to fill before sending.
|
|
97
|
+
# Defaults to 5.
|
|
98
|
+
# @option opts [Boolean] :test_mode +true+ if messages should remain queued for testing. Defaults to +false+.
|
|
99
|
+
# @option opts [Boolean] :sync_mode +true+ to send events synchronously on the calling thread. Useful in
|
|
100
|
+
# forking environments like Sidekiq and Resque. Defaults to +false+.
|
|
101
|
+
# @option opts [Proc] :on_error Callback invoked as `on_error.call(status, error)` for API or serialization errors.
|
|
102
|
+
# @option opts [Integer] :feature_flags_polling_interval How often to poll for feature flag definition changes,
|
|
103
|
+
# in seconds. Defaults to 30.
|
|
104
|
+
# @option opts [Integer] :feature_flag_request_timeout_seconds How long to wait for feature flag evaluation,
|
|
105
|
+
# in seconds. Defaults to 3.
|
|
106
|
+
# @option opts [Integer] :max_retries How many times to retry batch uploads after the first send attempt.
|
|
107
|
+
# Defaults to the transport default. Set to 0 to disable retrying.
|
|
108
|
+
# @option opts [Integer] :feature_flag_request_max_retries How many times to retry a flag request after a
|
|
109
|
+
# transient network error. Each retry sleeps on the calling thread before retrying, so this adds to
|
|
110
|
+
# worst-case latency. Defaults to 1. Set to 0 to disable retrying.
|
|
111
|
+
# @option opts [Boolean] :feature_flags_async_load +true+ to fetch feature flag definitions for local
|
|
112
|
+
# evaluation only on the poller's background thread instead of the calling thread. The constructor
|
|
113
|
+
# returns without waiting for definitions: the poller fetches immediately on boot and, if that fails,
|
|
114
|
+
# keeps retrying on its regular polling interval until a load succeeds. Until then local evaluation
|
|
115
|
+
# treats definitions as absent (check {#feature_flags_loaded?}), so evaluations return nil with
|
|
116
|
+
# +only_evaluate_locally: true+, or fall back to the remote flags endpoint (which still runs on the
|
|
117
|
+
# calling thread) without it. Defaults to +false+.
|
|
118
|
+
# @option opts [Proc] :before_send A callback that receives the event hash and should return either a modified
|
|
119
|
+
# hash to be sent to Txlemetry or nil to prevent the event from being sent. e.g. `before_send: ->(event) { event }`.
|
|
120
|
+
# @option opts [Boolean] :disable_singleton_warning +true+ to suppress the warning when multiple clients share
|
|
121
|
+
# the same API key. Use only when you intentionally need multiple clients. Defaults to +false+.
|
|
122
|
+
# @option opts [Boolean] :skip_ssl_verification +true+ to disable SSL certificate verification for requests.
|
|
123
|
+
# Intended only for local development or custom deployments.
|
|
124
|
+
# @option opts [Boolean] :compress_request Set to +false+ to disable gzip compression for batch uploads.
|
|
125
|
+
# @option opts [Object] :flag_definition_cache_provider An object implementing the {FlagDefinitionCacheProvider}
|
|
126
|
+
# interface for distributed flag definition caching.
|
|
127
|
+
# @option opts [Boolean] :is_server +true+ to stamp captured events with `$is_server => true` so Txlemetry
|
|
128
|
+
# attributes them as server-side. Defaults to +true+. Set to +false+ when running txlemetry-ruby as a
|
|
129
|
+
# client/CLI so the device OS is attributed normally and `$is_server` is omitted.
|
|
130
|
+
def initialize(opts = {})
|
|
131
|
+
symbolize_keys!(opts)
|
|
132
|
+
|
|
133
|
+
opts[:api_key] = normalize_string_option(opts[:api_key])
|
|
134
|
+
opts[:secret_key] = normalize_string_option(opts[:secret_key], blank_as_nil: true)
|
|
135
|
+
opts[:personal_api_key] = normalize_string_option(opts[:personal_api_key], blank_as_nil: true)
|
|
136
|
+
opts[:host] = normalize_host_option(opts[:host])
|
|
137
|
+
|
|
138
|
+
if opts[:secret_key].nil? && !opts[:personal_api_key].nil?
|
|
139
|
+
logger.warn(
|
|
140
|
+
'The :personal_api_key option is deprecated; use :secret_key instead. It accepts either a ' \
|
|
141
|
+
'Personal API Key (phx_...) or a Project Secret API Key (phs_...).'
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
secret_key = opts[:secret_key] || opts[:personal_api_key]
|
|
145
|
+
|
|
146
|
+
@queue = Queue.new
|
|
147
|
+
@queue_mutex = Mutex.new
|
|
148
|
+
@api_key = opts[:api_key]
|
|
149
|
+
@disabled = @api_key.nil? || @api_key.empty?
|
|
150
|
+
@max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
|
|
151
|
+
@lib = opts[:_lib] || 'txlemetry-ruby'
|
|
152
|
+
@lib_version = (opts[:_lib_version] || Txlemetry::VERSION).to_s
|
|
153
|
+
@headers = Defaults::Request::HEADERS.merge('User-Agent' => "#{@lib}/#{@lib_version}")
|
|
154
|
+
@worker_mutex = Mutex.new
|
|
155
|
+
@shutdown_mutex = Mutex.new
|
|
156
|
+
@shutdown_condition = ConditionVariable.new
|
|
157
|
+
@shutdown = false
|
|
158
|
+
@shutdown_complete = false
|
|
159
|
+
@shutdown_result = false
|
|
160
|
+
@sync_mode = opts[:sync_mode] == true && !opts[:test_mode] && !@disabled
|
|
161
|
+
@on_error = opts[:on_error] || proc { |status, error| }
|
|
162
|
+
@worker = if opts[:test_mode] || @disabled
|
|
163
|
+
NoopWorker.new(@queue)
|
|
164
|
+
elsif @sync_mode
|
|
165
|
+
nil
|
|
166
|
+
else
|
|
167
|
+
SendWorker.new(@queue, @api_key, opts.merge(headers: @headers))
|
|
168
|
+
end
|
|
169
|
+
if @sync_mode
|
|
170
|
+
@transport = Transport.new(
|
|
171
|
+
api_host: opts[:host],
|
|
172
|
+
headers: @headers,
|
|
173
|
+
skip_ssl_verification: opts[:skip_ssl_verification],
|
|
174
|
+
retries: opts.key?(:max_retries) ? opts[:max_retries].to_i + 1 : 3,
|
|
175
|
+
compress_request: opts[:compress_request]
|
|
176
|
+
)
|
|
177
|
+
@sync_lock = Mutex.new
|
|
178
|
+
end
|
|
179
|
+
@worker_thread = nil
|
|
180
|
+
@feature_flags_poller = nil
|
|
181
|
+
@secret_key = secret_key
|
|
182
|
+
@personal_api_key = secret_key
|
|
183
|
+
|
|
184
|
+
if @disabled && !opts[:silence_disabled_client_error]
|
|
185
|
+
logger.error('api_key is missing or empty after trimming whitespace; check your project API key')
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Warn when multiple clients are created with the same API key (can cause dropped events)
|
|
189
|
+
unless @disabled || opts[:test_mode] || opts[:disable_singleton_warning]
|
|
190
|
+
previous_count = Txlemetry::Client._increment_instance_count(@api_key)
|
|
191
|
+
@instance_registered = true
|
|
192
|
+
if previous_count >= 1
|
|
193
|
+
logger.warn(
|
|
194
|
+
'Multiple Txlemetry client instances detected for the same API key. ' \
|
|
195
|
+
'This can cause dropped events and inconsistent behavior. ' \
|
|
196
|
+
'Use a singleton pattern: instantiate once and reuse the client. ' \
|
|
197
|
+
'See https://txlemetry.com/docs/libraries/ruby'
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Initialize tracking before the poller can load definitions (including async loads).
|
|
203
|
+
@distinct_id_has_sent_flag_calls_mutex = Mutex.new
|
|
204
|
+
@distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) do |hash, key|
|
|
205
|
+
hash[key] = SizeLimitedArray.new(Defaults::MAX_HASH_SIZE)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
unless @disabled
|
|
209
|
+
@feature_flags_poller =
|
|
210
|
+
FeatureFlagsPoller.new(
|
|
211
|
+
opts[:feature_flags_polling_interval],
|
|
212
|
+
secret_key,
|
|
213
|
+
@api_key,
|
|
214
|
+
opts[:host],
|
|
215
|
+
opts[:feature_flag_request_timeout_seconds] || Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS,
|
|
216
|
+
opts[:on_error],
|
|
217
|
+
flag_definition_cache_provider: opts[:flag_definition_cache_provider],
|
|
218
|
+
feature_flag_request_max_retries: opts[:feature_flag_request_max_retries],
|
|
219
|
+
async_load: opts[:feature_flags_async_load] == true,
|
|
220
|
+
user_agent: @headers['User-Agent'],
|
|
221
|
+
flag_definitions_update_mutex: @distinct_id_has_sent_flag_calls_mutex,
|
|
222
|
+
on_flag_definitions_updated: -> { @distinct_id_has_sent_flag_calls.clear }
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
@before_send = opts[:before_send]
|
|
227
|
+
@is_server = opts.fetch(:is_server, true) != false
|
|
228
|
+
@release_id = normalize_string_option(ENV.fetch(RELEASE_ID_ENV_VAR) { ENV['POSTHOG_RELEASE_ID'] }, blank_as_nil: true)
|
|
229
|
+
@deprecation_emitted_for = Concurrent::Set.new
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# When sync_mode is enabled, blocks until in-flight requests are complete
|
|
233
|
+
# and returns true. Timeout parameter has no effect.
|
|
234
|
+
#
|
|
235
|
+
# Otherwise, waits until the worker has cleared the queue or timeout is hit.
|
|
236
|
+
# Note: The asynchronous wait polls and can be starved if another thread is
|
|
237
|
+
# actively continuing to enqueue new events.
|
|
238
|
+
#
|
|
239
|
+
# @param timeout [Numeric, nil] Maximum seconds to wait for pending events
|
|
240
|
+
# to be sent, or +nil+ to wait indefinitely.
|
|
241
|
+
# @return [Boolean] +true+ if all pending events were sent, +false+ if the
|
|
242
|
+
# timeout elapsed first.
|
|
243
|
+
def flush(timeout: nil)
|
|
244
|
+
if @sync_mode
|
|
245
|
+
# Wait for any in-flight sync send to complete
|
|
246
|
+
@sync_lock.synchronize {} # rubocop:disable Lint/EmptyBlock
|
|
247
|
+
return true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if @worker.is_a?(NoopWorker)
|
|
251
|
+
clear
|
|
252
|
+
return true
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
deadline = timeout && (monotonic_time + timeout)
|
|
256
|
+
|
|
257
|
+
while !@queue.empty? || @worker.is_requesting?
|
|
258
|
+
ensure_worker_running
|
|
259
|
+
@worker.request_flush
|
|
260
|
+
remaining = deadline && (deadline - monotonic_time)
|
|
261
|
+
return false if remaining && remaining <= 0
|
|
262
|
+
|
|
263
|
+
sleep(remaining ? remaining.clamp(0, 0.1) : 0.1)
|
|
264
|
+
end
|
|
265
|
+
true
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Clears the queue without waiting.
|
|
269
|
+
#
|
|
270
|
+
# Use only in test mode.
|
|
271
|
+
#
|
|
272
|
+
# @return [void]
|
|
273
|
+
def clear
|
|
274
|
+
@queue_mutex.synchronize { @queue.clear }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# @!macro common_attrs
|
|
278
|
+
# @option attrs [String] :message_id Deprecated. Use `:uuid` instead. If `:uuid` is absent or
|
|
279
|
+
# invalid and `:message_id` is a valid UUID, it is sent as `uuid` for backwards compatibility.
|
|
280
|
+
# If neither value is valid, the SDK generates a `uuid`. SDK metadata is sent as `$lib` and
|
|
281
|
+
# `$lib_version` properties.
|
|
282
|
+
# @option attrs [Time] :timestamp When the event occurred (optional). UTC is preferred;
|
|
283
|
+
# non-UTC values are converted to the equivalent UTC instant before serialization.
|
|
284
|
+
# @option attrs [String] :distinct_id The ID for this user in your database
|
|
285
|
+
|
|
286
|
+
# Captures an event
|
|
287
|
+
#
|
|
288
|
+
# @param [Hash] attrs
|
|
289
|
+
#
|
|
290
|
+
# @option attrs [String] :event Event name
|
|
291
|
+
# @option attrs [Hash] :properties Event properties (optional)
|
|
292
|
+
# @option attrs [Hash] :groups Group analytics mapping from group type to group key (optional)
|
|
293
|
+
# @option attrs [Boolean, Hash, SendFeatureFlagsOptions] :send_feature_flags
|
|
294
|
+
# Deprecated. Whether to send feature flags with this event, or configuration for feature flag evaluation
|
|
295
|
+
# (optional)
|
|
296
|
+
# @option attrs [Txlemetry::FeatureFlagEvaluations] :flags A snapshot returned by
|
|
297
|
+
# {#evaluate_flags}. When present, `$feature/<key>` and `$active_feature_flags` are
|
|
298
|
+
# attached from the snapshot without making an additional /flags request, and this
|
|
299
|
+
# takes precedence over `:send_feature_flags`.
|
|
300
|
+
# @option attrs [String] :uuid ID that uniquely identifies an event;
|
|
301
|
+
# events in Txlemetry are deduplicated by the
|
|
302
|
+
# combination of teamId, timestamp date,
|
|
303
|
+
# event name, distinct id, and UUID
|
|
304
|
+
# @note If `:distinct_id` is omitted, request/context distinct_id is used when
|
|
305
|
+
# available; otherwise a UUID is generated and the event is marked personless
|
|
306
|
+
# with `$process_person_profile: false`.
|
|
307
|
+
# @return [Boolean] Whether the event was queued or sent.
|
|
308
|
+
# @macro common_attrs
|
|
309
|
+
def capture(attrs)
|
|
310
|
+
return false if @disabled
|
|
311
|
+
|
|
312
|
+
symbolize_keys! attrs
|
|
313
|
+
minimal_flag_called_event = attrs.delete(:_minimal_flag_called_event) == true
|
|
314
|
+
# Integrations that ride on a host application's client (e.g. Txlemetry::MCP)
|
|
315
|
+
# may relabel a single event's `$lib`/`$lib_version` without relabeling the
|
|
316
|
+
# client or its User-Agent.
|
|
317
|
+
lib_override = attrs.delete(:_lib)
|
|
318
|
+
lib_version_override = attrs.delete(:_lib_version)
|
|
319
|
+
enrich_capture_attrs_with_context(attrs)
|
|
320
|
+
|
|
321
|
+
# Precedence: an explicit `flags` snapshot always wins, regardless of
|
|
322
|
+
# `send_feature_flags`. The snapshot guarantees the event carries the same
|
|
323
|
+
# values the developer branched on with no additional network call.
|
|
324
|
+
if attrs[:flags]
|
|
325
|
+
if attrs[:flags].is_a?(FeatureFlagEvaluations)
|
|
326
|
+
if attrs[:send_feature_flags]
|
|
327
|
+
logger.warn(
|
|
328
|
+
'[FEATURE FLAGS] Both `flags` and `send_feature_flags` were passed to ' \
|
|
329
|
+
'capture(); using `flags` and ignoring `send_feature_flags`.'
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
snapshot_props = attrs[:flags]._get_event_properties
|
|
333
|
+
attrs[:properties] = snapshot_props.merge(attrs[:properties] || {})
|
|
334
|
+
attrs.delete(:flags)
|
|
335
|
+
attrs.delete(:send_feature_flags)
|
|
336
|
+
else
|
|
337
|
+
logger.warn(
|
|
338
|
+
'[FEATURE FLAGS] capture(flags:) expects a Txlemetry::FeatureFlagEvaluations snapshot ' \
|
|
339
|
+
"from `client.evaluate_flags(...)`; got #{attrs[:flags].class}. Ignoring."
|
|
340
|
+
)
|
|
341
|
+
attrs.delete(:flags)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
send_feature_flags_param = attrs[:send_feature_flags]
|
|
346
|
+
if send_feature_flags_param && !@disabled
|
|
347
|
+
_emit_deprecation(
|
|
348
|
+
:capture_send_feature_flags,
|
|
349
|
+
'`send_feature_flags` on `capture` is deprecated and will be removed in a future major ' \
|
|
350
|
+
'version. Pass a `flags` snapshot from `client.evaluate_flags(...)` instead — it ' \
|
|
351
|
+
'avoids a second `/flags` request per capture and guarantees the event carries the ' \
|
|
352
|
+
'exact flag values your code branched on.'
|
|
353
|
+
)
|
|
354
|
+
# Handle different types of send_feature_flags parameter
|
|
355
|
+
case send_feature_flags_param
|
|
356
|
+
when true
|
|
357
|
+
# Backward compatibility: simple boolean
|
|
358
|
+
feature_variants = @feature_flags_poller.get_feature_variants(attrs[:distinct_id], attrs[:groups] || {})
|
|
359
|
+
when Hash
|
|
360
|
+
# Hash with options
|
|
361
|
+
options = SendFeatureFlagsOptions.from_hash(send_feature_flags_param)
|
|
362
|
+
feature_variants = @feature_flags_poller.get_feature_variants(
|
|
363
|
+
attrs[:distinct_id],
|
|
364
|
+
attrs[:groups] || {},
|
|
365
|
+
options ? options.person_properties : {},
|
|
366
|
+
options ? options.group_properties : {},
|
|
367
|
+
options ? options.only_evaluate_locally : false
|
|
368
|
+
)
|
|
369
|
+
when SendFeatureFlagsOptions
|
|
370
|
+
# SendFeatureFlagsOptions object
|
|
371
|
+
feature_variants = @feature_flags_poller.get_feature_variants(
|
|
372
|
+
attrs[:distinct_id],
|
|
373
|
+
attrs[:groups] || {},
|
|
374
|
+
send_feature_flags_param.person_properties,
|
|
375
|
+
send_feature_flags_param.group_properties,
|
|
376
|
+
send_feature_flags_param.only_evaluate_locally || false
|
|
377
|
+
)
|
|
378
|
+
else
|
|
379
|
+
# Invalid type, treat as false
|
|
380
|
+
feature_variants = nil
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
attrs[:feature_variants] = feature_variants if feature_variants
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
attrs[:is_server] = @is_server
|
|
387
|
+
attrs[:lib] = lib_override || @lib
|
|
388
|
+
attrs[:lib_version] = (lib_version_override || @lib_version).to_s
|
|
389
|
+
message = FieldParser.parse_for_capture(attrs)
|
|
390
|
+
# Minimal events are built from the allowlist after full assembly so
|
|
391
|
+
# context properties and parser-added metadata can never leak in.
|
|
392
|
+
if minimal_flag_called_event
|
|
393
|
+
message[:properties] = message[:properties].slice(*MINIMAL_FLAG_CALLED_EVENT_PROPERTIES)
|
|
394
|
+
end
|
|
395
|
+
enqueue(message)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Captures an exception as an event
|
|
399
|
+
#
|
|
400
|
+
# @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object
|
|
401
|
+
# @param [String] distinct_id The ID for the user (optional, defaults to request/context distinct_id
|
|
402
|
+
# or a generated UUID)
|
|
403
|
+
# @param [Hash] additional_properties Additional properties to include with the exception event (optional)
|
|
404
|
+
# @param flags [Txlemetry::FeatureFlagEvaluations, nil] A snapshot returned by {#evaluate_flags}.
|
|
405
|
+
# Forwarded to the inner {#capture} call so the captured `$exception` event carries the
|
|
406
|
+
# same `$feature/<key>` and `$active_feature_flags` properties as the snapshot.
|
|
407
|
+
# @param mechanism [Hash, nil] How the exception was captured, e.g.
|
|
408
|
+
# `{ 'type' => 'rails', 'handled' => false }` for automatic integrations.
|
|
409
|
+
# Defaults to `{ 'type' => 'generic', 'handled' => true }` for manual captures.
|
|
410
|
+
# @return [Boolean, nil] Whether the exception event was queued or sent, or nil if the input could not be parsed.
|
|
411
|
+
def capture_exception(exception, distinct_id = nil, additional_properties = {}, flags: nil, mechanism: nil)
|
|
412
|
+
return false if @disabled
|
|
413
|
+
|
|
414
|
+
exception_list = ExceptionCapture.build_exception_list(exception, mechanism: mechanism)
|
|
415
|
+
|
|
416
|
+
return if exception_list.nil?
|
|
417
|
+
|
|
418
|
+
properties = { '$exception_list' => exception_list }
|
|
419
|
+
properties.merge!(additional_properties) if additional_properties && !additional_properties.empty?
|
|
420
|
+
|
|
421
|
+
event_data = {
|
|
422
|
+
distinct_id: distinct_id,
|
|
423
|
+
event: '$exception',
|
|
424
|
+
properties: properties,
|
|
425
|
+
timestamp: Time.now
|
|
426
|
+
}
|
|
427
|
+
event_data[:flags] = flags if flags
|
|
428
|
+
|
|
429
|
+
capture(event_data)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Identifies a user
|
|
433
|
+
#
|
|
434
|
+
# @param [Hash] attrs
|
|
435
|
+
#
|
|
436
|
+
# @option attrs [Hash] :properties User properties (optional)
|
|
437
|
+
# @return [Boolean] Whether the identify event was queued or sent.
|
|
438
|
+
# @macro common_attrs
|
|
439
|
+
def identify(attrs)
|
|
440
|
+
return false if @disabled
|
|
441
|
+
|
|
442
|
+
symbolize_keys! attrs
|
|
443
|
+
attrs[:is_server] = @is_server
|
|
444
|
+
attrs[:lib] = @lib
|
|
445
|
+
attrs[:lib_version] = @lib_version
|
|
446
|
+
enqueue(FieldParser.parse_for_identify(attrs))
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Identifies a group
|
|
450
|
+
#
|
|
451
|
+
# @param [Hash] attrs
|
|
452
|
+
#
|
|
453
|
+
# @option attrs [String] :group_type Group type
|
|
454
|
+
# @option attrs [String] :group_key Group key
|
|
455
|
+
# @option attrs [Hash] :properties Group properties (optional)
|
|
456
|
+
# @option attrs [String] :distinct_id Distinct ID (optional)
|
|
457
|
+
# @return [Boolean] Whether the group identify event was queued or sent.
|
|
458
|
+
# @macro common_attrs
|
|
459
|
+
def group_identify(attrs)
|
|
460
|
+
return false if @disabled
|
|
461
|
+
|
|
462
|
+
symbolize_keys! attrs
|
|
463
|
+
attrs[:is_server] = @is_server
|
|
464
|
+
attrs[:lib] = @lib
|
|
465
|
+
attrs[:lib_version] = @lib_version
|
|
466
|
+
enqueue(FieldParser.parse_for_group_identify(attrs))
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Aliases a user from one id to another
|
|
470
|
+
#
|
|
471
|
+
# @param [Hash] attrs
|
|
472
|
+
#
|
|
473
|
+
# @option attrs [String] :alias The alias to give the distinct id
|
|
474
|
+
# @return [Boolean] Whether the alias event was queued or sent.
|
|
475
|
+
# @macro common_attrs
|
|
476
|
+
def alias(attrs)
|
|
477
|
+
return false if @disabled
|
|
478
|
+
|
|
479
|
+
symbolize_keys! attrs
|
|
480
|
+
attrs[:is_server] = @is_server
|
|
481
|
+
attrs[:lib] = @lib
|
|
482
|
+
attrs[:lib_version] = @lib_version
|
|
483
|
+
enqueue(FieldParser.parse_for_alias(attrs))
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# @return [Hash] Pops the last message from the queue. Intended for test mode.
|
|
487
|
+
def dequeue_last_message
|
|
488
|
+
@queue.pop
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# @return [Integer] Number of messages in the queue. Intended for test mode.
|
|
492
|
+
def queued_messages
|
|
493
|
+
@queue.length
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#enabled?} instead.
|
|
497
|
+
# @param flag_key [String, Symbol] The unique key of the feature flag.
|
|
498
|
+
# @param distinct_id [String] The distinct id of the user.
|
|
499
|
+
# @param groups [Hash] Group analytics mapping from group type to group key.
|
|
500
|
+
# @param person_properties [Hash] Properties to use when evaluating the user locally or remotely.
|
|
501
|
+
# @param group_properties [Hash] Properties to use when evaluating groups locally or remotely.
|
|
502
|
+
# @param only_evaluate_locally [Boolean] Skip the remote /flags call.
|
|
503
|
+
# @param send_feature_flag_events [Boolean] Whether to capture `$feature_flag_called` for this access.
|
|
504
|
+
# @return [Boolean, nil] Whether the flag is enabled, or nil when the flag could not be evaluated.
|
|
505
|
+
# TODO: In future version, rename to `feature_flag_enabled?`
|
|
506
|
+
def is_feature_enabled( # rubocop:disable Naming/PredicatePrefix
|
|
507
|
+
flag_key,
|
|
508
|
+
distinct_id,
|
|
509
|
+
groups: {},
|
|
510
|
+
person_properties: {},
|
|
511
|
+
group_properties: {},
|
|
512
|
+
only_evaluate_locally: false,
|
|
513
|
+
send_feature_flag_events: true
|
|
514
|
+
)
|
|
515
|
+
_emit_deprecation(
|
|
516
|
+
:is_feature_enabled,
|
|
517
|
+
'`is_feature_enabled` is deprecated and will be removed in a future major version. ' \
|
|
518
|
+
'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.enabled?(key)` instead — ' \
|
|
519
|
+
'this consolidates flag evaluation into a single `/flags` request per incoming request.'
|
|
520
|
+
)
|
|
521
|
+
# Bypass the public `get_feature_flag` so the user only sees a single deprecation
|
|
522
|
+
# warning per call, not a cascade.
|
|
523
|
+
result = _get_feature_flag_result(
|
|
524
|
+
flag_key, distinct_id,
|
|
525
|
+
groups: groups, person_properties: person_properties, group_properties: group_properties,
|
|
526
|
+
only_evaluate_locally: only_evaluate_locally, send_feature_flag_events: send_feature_flag_events
|
|
527
|
+
)
|
|
528
|
+
response = result&.value
|
|
529
|
+
return nil if response.nil?
|
|
530
|
+
|
|
531
|
+
!!response
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# @param flag_key [String, Symbol] The unique flag key of the remote config feature flag.
|
|
535
|
+
# @return [Hash] The parsed remote config payload response.
|
|
536
|
+
def get_remote_config_payload(flag_key)
|
|
537
|
+
return nil if @disabled
|
|
538
|
+
|
|
539
|
+
@feature_flags_poller.get_remote_config_payload(flag_key.to_s)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Returns whether the given feature flag is enabled for the given user or not
|
|
543
|
+
#
|
|
544
|
+
# @param [String, Symbol] key The key of the feature flag
|
|
545
|
+
# @param [String] distinct_id The distinct id of the user
|
|
546
|
+
# @param [Hash] groups
|
|
547
|
+
# @param [Hash] person_properties key-value pairs of properties to associate with the user.
|
|
548
|
+
# @param [Hash] group_properties
|
|
549
|
+
# @param only_evaluate_locally [Boolean] Skip the remote /flags call.
|
|
550
|
+
# @param send_feature_flag_events [Boolean] Whether to capture `$feature_flag_called` for this access.
|
|
551
|
+
#
|
|
552
|
+
# @return [String, Boolean, nil] The value of the feature flag
|
|
553
|
+
#
|
|
554
|
+
# The provided properties are used to calculate feature flags locally, if possible.
|
|
555
|
+
#
|
|
556
|
+
# `groups` are a mapping from group type to group key. So, if you have a group type of "organization"
|
|
557
|
+
# and a group key of "5",
|
|
558
|
+
# you would pass groups={"organization": "5"}.
|
|
559
|
+
# `group_properties` take the format: { group_type_name: { group_properties } }
|
|
560
|
+
# So, for example, if you have the group type "organization" and the group key "5", with the properties name,
|
|
561
|
+
# and employee count, you'll send these as:
|
|
562
|
+
# ```ruby
|
|
563
|
+
# group_properties: {"organization": {"name": "Txlemetry", "employees": 11}}
|
|
564
|
+
# ```
|
|
565
|
+
# @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag} instead.
|
|
566
|
+
def get_feature_flag(
|
|
567
|
+
key,
|
|
568
|
+
distinct_id,
|
|
569
|
+
groups: {},
|
|
570
|
+
person_properties: {},
|
|
571
|
+
group_properties: {},
|
|
572
|
+
only_evaluate_locally: false,
|
|
573
|
+
send_feature_flag_events: true
|
|
574
|
+
)
|
|
575
|
+
_emit_deprecation(
|
|
576
|
+
:get_feature_flag,
|
|
577
|
+
'`get_feature_flag` is deprecated and will be removed in a future major version. ' \
|
|
578
|
+
'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag(key)` instead — ' \
|
|
579
|
+
'this consolidates flag evaluation into a single `/flags` request per incoming request.'
|
|
580
|
+
)
|
|
581
|
+
# Bypass the public `get_feature_flag_result` so the user only sees one deprecation warning.
|
|
582
|
+
result = _get_feature_flag_result(
|
|
583
|
+
key, distinct_id,
|
|
584
|
+
groups: groups, person_properties: person_properties, group_properties: group_properties,
|
|
585
|
+
only_evaluate_locally: only_evaluate_locally, send_feature_flag_events: send_feature_flag_events
|
|
586
|
+
)
|
|
587
|
+
result&.value
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag} /
|
|
591
|
+
# {FeatureFlagEvaluations#get_flag_payload} instead.
|
|
592
|
+
# @param key [String, Symbol] The unique key of the feature flag.
|
|
593
|
+
# @param distinct_id [String] The distinct id of the user.
|
|
594
|
+
# @param groups [Hash] Group analytics mapping from group type to group key.
|
|
595
|
+
# @param person_properties [Hash] Properties to use when evaluating the user locally or remotely.
|
|
596
|
+
# @param group_properties [Hash] Properties to use when evaluating groups locally or remotely.
|
|
597
|
+
# @param only_evaluate_locally [Boolean] Skip the remote /flags call.
|
|
598
|
+
# @param send_feature_flag_events [Boolean] Whether to capture `$feature_flag_called` for this access.
|
|
599
|
+
# @return [Txlemetry::FeatureFlagResult, nil]
|
|
600
|
+
def get_feature_flag_result(
|
|
601
|
+
key,
|
|
602
|
+
distinct_id,
|
|
603
|
+
groups: {},
|
|
604
|
+
person_properties: {},
|
|
605
|
+
group_properties: {},
|
|
606
|
+
only_evaluate_locally: false,
|
|
607
|
+
send_feature_flag_events: true
|
|
608
|
+
)
|
|
609
|
+
_emit_deprecation(
|
|
610
|
+
:get_feature_flag_result,
|
|
611
|
+
'`get_feature_flag_result` is deprecated and will be removed in a future major version. ' \
|
|
612
|
+
'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag(key)` / ' \
|
|
613
|
+
'`flags.get_flag_payload(key)` instead — this consolidates flag evaluation into a single ' \
|
|
614
|
+
'`/flags` request per incoming request.'
|
|
615
|
+
)
|
|
616
|
+
return nil if @disabled
|
|
617
|
+
|
|
618
|
+
_get_feature_flag_result(
|
|
619
|
+
key, distinct_id,
|
|
620
|
+
groups: groups, person_properties: person_properties, group_properties: group_properties,
|
|
621
|
+
only_evaluate_locally: only_evaluate_locally, send_feature_flag_events: send_feature_flag_events
|
|
622
|
+
)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Evaluate feature flags for a distinct id and return a snapshot.
|
|
626
|
+
#
|
|
627
|
+
# The returned {Txlemetry::FeatureFlagEvaluations} can be queried with
|
|
628
|
+
# `is_enabled` / `get_flag` / `get_flag_payload`, narrowed with
|
|
629
|
+
# `only_accessed` / `only`, and passed to {#capture} via the `flags:` option
|
|
630
|
+
# to attach `$feature/<key>` and `$active_feature_flags` without an extra
|
|
631
|
+
# /flags request.
|
|
632
|
+
#
|
|
633
|
+
# @param [String] distinct_id The distinct id of the user
|
|
634
|
+
# @param [Hash] groups
|
|
635
|
+
# @param [Hash] person_properties key-value pairs of properties to associate with the user
|
|
636
|
+
# @param [Hash] group_properties
|
|
637
|
+
# @param [Boolean] only_evaluate_locally Skip the remote /flags call entirely
|
|
638
|
+
# @param [Boolean, nil] disable_geoip When true, disables GeoIP lookup for remote evaluation and stamps captured
|
|
639
|
+
# access events.
|
|
640
|
+
# @param [Array<String, Symbol>, nil] flag_keys When set, scopes evaluation to only these flag keys.
|
|
641
|
+
# An empty array returns an empty snapshot without evaluating flags; +nil+ evaluates all flags.
|
|
642
|
+
# Distinct from {FeatureFlagEvaluations#only}, which filters the already-fetched snapshot in memory.
|
|
643
|
+
# @return [Txlemetry::FeatureFlagEvaluations]
|
|
644
|
+
def evaluate_flags(
|
|
645
|
+
distinct_id,
|
|
646
|
+
groups: {},
|
|
647
|
+
person_properties: {},
|
|
648
|
+
group_properties: {},
|
|
649
|
+
only_evaluate_locally: false,
|
|
650
|
+
disable_geoip: nil,
|
|
651
|
+
flag_keys: nil
|
|
652
|
+
)
|
|
653
|
+
host = _feature_flag_evaluations_host
|
|
654
|
+
|
|
655
|
+
if distinct_id.nil? || distinct_id.to_s.empty?
|
|
656
|
+
return FeatureFlagEvaluations.new(host: host, distinct_id: '', flags: {})
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
return FeatureFlagEvaluations.new(host: host, distinct_id: distinct_id, flags: {}, groups: groups) if @disabled
|
|
660
|
+
|
|
661
|
+
if flag_keys && flag_keys.empty?
|
|
662
|
+
return FeatureFlagEvaluations.new(
|
|
663
|
+
host: host, distinct_id: distinct_id, flags: {}, groups: groups, disable_geoip: disable_geoip
|
|
664
|
+
)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
person_properties, group_properties = add_local_person_and_group_properties(
|
|
668
|
+
groups, person_properties, group_properties
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
records = {}
|
|
672
|
+
locally_evaluated_keys = Set.new
|
|
673
|
+
flag_keys_set = flag_keys&.to_set(&:to_s)
|
|
674
|
+
|
|
675
|
+
@feature_flags_poller.load_feature_flags
|
|
676
|
+
definition_snapshot = @feature_flags_poller._evaluation_snapshot
|
|
677
|
+
poller_flags_by_key = definition_snapshot[:flags_by_key] || {}
|
|
678
|
+
|
|
679
|
+
poller_flags_by_key.each do |key, definition|
|
|
680
|
+
next if flag_keys_set && !flag_keys_set.include?(key.to_s)
|
|
681
|
+
|
|
682
|
+
begin
|
|
683
|
+
match = @feature_flags_poller.send(
|
|
684
|
+
:_compute_flag_locally,
|
|
685
|
+
definition, distinct_id, groups, person_properties, group_properties, snapshot: definition_snapshot
|
|
686
|
+
)
|
|
687
|
+
rescue Txlemetry::RequiresServerEvaluation, Txlemetry::InconclusiveMatchError, StandardError
|
|
688
|
+
next
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
next if match.nil?
|
|
692
|
+
|
|
693
|
+
records[key.to_s] = FeatureFlagEvaluations::EvaluatedFlagRecord.new(
|
|
694
|
+
key: key.to_s,
|
|
695
|
+
enabled: match.is_a?(String) || (match ? true : false),
|
|
696
|
+
variant: match.is_a?(String) ? match : nil,
|
|
697
|
+
payload: FeatureFlagResult.parse_payload(
|
|
698
|
+
@feature_flags_poller.send(:_compute_flag_payload_locally, key, match, snapshot: definition_snapshot)
|
|
699
|
+
),
|
|
700
|
+
id: definition[:id],
|
|
701
|
+
version: nil,
|
|
702
|
+
reason: FeatureFlagEvaluations::EVALUATED_LOCALLY_REASON,
|
|
703
|
+
locally_evaluated: true,
|
|
704
|
+
has_experiment: definition[:has_experiment]
|
|
705
|
+
)
|
|
706
|
+
locally_evaluated_keys << key.to_s
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
request_id = nil
|
|
710
|
+
evaluated_at = nil
|
|
711
|
+
errors_while_computing = false
|
|
712
|
+
quota_limited = false
|
|
713
|
+
# Server-controlled gate for minimal `$feature_flag_called` events. When
|
|
714
|
+
# the snapshot uses a remote /flags response, the response's top-level
|
|
715
|
+
# `minimalFlagCalledEvents` field governs; a local-only snapshot reads
|
|
716
|
+
# the gate polled with the flag definitions.
|
|
717
|
+
minimal_flag_called_events = definition_snapshot[:minimal_flag_called_events]
|
|
718
|
+
|
|
719
|
+
# Skip the remote `/flags` round-trip when the caller scoped the request
|
|
720
|
+
# to a fixed set of `flag_keys` and we've already resolved every one of
|
|
721
|
+
# them locally. Without `flag_keys` set, we can't know whether the server
|
|
722
|
+
# has flags we don't have definitions for, so we still hit `/flags`.
|
|
723
|
+
all_requested_flags_resolved_locally = flag_keys_set && (flag_keys_set - locally_evaluated_keys).empty?
|
|
724
|
+
|
|
725
|
+
if !only_evaluate_locally && !all_requested_flags_resolved_locally
|
|
726
|
+
# The gate is team-level, so the /flags response gate supersedes the
|
|
727
|
+
# poller gate for mixed snapshots — both signals come from the same
|
|
728
|
+
# server and agree in steady state. When the response omits the gate,
|
|
729
|
+
# the whole snapshot fails safe to full events.
|
|
730
|
+
minimal_flag_called_events = false
|
|
731
|
+
begin
|
|
732
|
+
flags_response = @feature_flags_poller.get_flags(
|
|
733
|
+
distinct_id, groups, person_properties, group_properties, flag_keys, disable_geoip
|
|
734
|
+
)
|
|
735
|
+
minimal_flag_called_events = flags_response[:minimalFlagCalledEvents] == true
|
|
736
|
+
request_id = flags_response[:requestId]
|
|
737
|
+
evaluated_at = flags_response[:evaluatedAt]
|
|
738
|
+
errors_while_computing = flags_response[:errorsWhileComputingFlags] == true
|
|
739
|
+
quota_limited = (flags_response[:quotaLimited] || []).include?('feature_flags')
|
|
740
|
+
remote_flags = flags_response[:flags] || {}
|
|
741
|
+
remote_flags.each do |key, ff|
|
|
742
|
+
key_str = key.to_s
|
|
743
|
+
next if locally_evaluated_keys.include?(key_str)
|
|
744
|
+
|
|
745
|
+
metadata = ff.metadata
|
|
746
|
+
reason = ff.reason
|
|
747
|
+
records[key_str] = FeatureFlagEvaluations::EvaluatedFlagRecord.new(
|
|
748
|
+
key: key_str,
|
|
749
|
+
enabled: ff.enabled ? true : false,
|
|
750
|
+
variant: ff.variant,
|
|
751
|
+
payload: FeatureFlagResult.parse_payload(ff.payload),
|
|
752
|
+
id: metadata&.id,
|
|
753
|
+
version: metadata&.version,
|
|
754
|
+
reason: reason ? (reason.description || reason.code) : nil,
|
|
755
|
+
locally_evaluated: false,
|
|
756
|
+
has_experiment: metadata&.has_experiment
|
|
757
|
+
)
|
|
758
|
+
end
|
|
759
|
+
rescue StandardError => e
|
|
760
|
+
@on_error&.call(-1, "Error evaluating flags remotely: #{e}")
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
FeatureFlagEvaluations.new(
|
|
765
|
+
host: host,
|
|
766
|
+
distinct_id: distinct_id,
|
|
767
|
+
flags: records,
|
|
768
|
+
groups: groups,
|
|
769
|
+
disable_geoip: disable_geoip,
|
|
770
|
+
request_id: request_id,
|
|
771
|
+
evaluated_at: evaluated_at,
|
|
772
|
+
flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at,
|
|
773
|
+
errors_while_computing: errors_while_computing,
|
|
774
|
+
quota_limited: quota_limited,
|
|
775
|
+
minimal_flag_called_events: minimal_flag_called_events
|
|
776
|
+
)
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Returns all flags for a given user
|
|
780
|
+
#
|
|
781
|
+
# @param [String] distinct_id The distinct id of the user
|
|
782
|
+
# @param [Hash] groups
|
|
783
|
+
# @param [Hash] person_properties key-value pairs of properties to associate with the user.
|
|
784
|
+
# @param [Hash] group_properties
|
|
785
|
+
# @param only_evaluate_locally [Boolean] Skip the remote /flags call.
|
|
786
|
+
#
|
|
787
|
+
# @return [Hash] String (not symbol) key value pairs of flag and their values
|
|
788
|
+
def get_all_flags(
|
|
789
|
+
distinct_id,
|
|
790
|
+
groups: {},
|
|
791
|
+
person_properties: {},
|
|
792
|
+
group_properties: {},
|
|
793
|
+
only_evaluate_locally: false
|
|
794
|
+
)
|
|
795
|
+
return {} if @disabled
|
|
796
|
+
|
|
797
|
+
person_properties, group_properties = add_local_person_and_group_properties(groups, person_properties,
|
|
798
|
+
group_properties)
|
|
799
|
+
@feature_flags_poller.get_all_flags(distinct_id, groups, person_properties, group_properties,
|
|
800
|
+
only_evaluate_locally)
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Returns payload for a given feature flag
|
|
804
|
+
#
|
|
805
|
+
# @deprecated Use {#get_feature_flag_result} instead, which returns both the flag value and payload
|
|
806
|
+
# and properly raises the $feature_flag_called event.
|
|
807
|
+
#
|
|
808
|
+
# @param [String, Symbol] key The key of the feature flag
|
|
809
|
+
# @param [String] distinct_id The distinct id of the user
|
|
810
|
+
# @param match_value [String, Boolean, nil] The value of the feature flag to be matched
|
|
811
|
+
# @param groups [Hash]
|
|
812
|
+
# @param person_properties [Hash] key-value pairs of properties to associate with the user.
|
|
813
|
+
# @param group_properties [Hash]
|
|
814
|
+
# @param only_evaluate_locally [Boolean]
|
|
815
|
+
# @return [Object, nil] The parsed payload for the matched flag value.
|
|
816
|
+
#
|
|
817
|
+
# @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag_payload} instead.
|
|
818
|
+
def get_feature_flag_payload(
|
|
819
|
+
key,
|
|
820
|
+
distinct_id,
|
|
821
|
+
match_value: nil,
|
|
822
|
+
groups: {},
|
|
823
|
+
person_properties: {},
|
|
824
|
+
group_properties: {},
|
|
825
|
+
only_evaluate_locally: false
|
|
826
|
+
)
|
|
827
|
+
_emit_deprecation(
|
|
828
|
+
:get_feature_flag_payload,
|
|
829
|
+
'`get_feature_flag_payload` is deprecated and will be removed in a future major version. ' \
|
|
830
|
+
'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag_payload(key)` ' \
|
|
831
|
+
'instead — this consolidates flag evaluation into a single `/flags` request per ' \
|
|
832
|
+
'incoming request.'
|
|
833
|
+
)
|
|
834
|
+
return nil if @disabled
|
|
835
|
+
|
|
836
|
+
key = key.to_s
|
|
837
|
+
person_properties, group_properties = add_local_person_and_group_properties(groups, person_properties,
|
|
838
|
+
group_properties)
|
|
839
|
+
@feature_flags_poller.get_feature_flag_payload(key, distinct_id, match_value, groups, person_properties,
|
|
840
|
+
group_properties, only_evaluate_locally)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# Returns all flags and payloads for a given user
|
|
844
|
+
#
|
|
845
|
+
# @return [Hash] A hash with the following keys:
|
|
846
|
+
# featureFlags: A hash of feature flags
|
|
847
|
+
# featureFlagPayloads: A hash of feature flag payloads
|
|
848
|
+
#
|
|
849
|
+
# @param [String] distinct_id The distinct id of the user
|
|
850
|
+
# @param groups [Hash]
|
|
851
|
+
# @param person_properties [Hash] key-value pairs of properties to associate with the user.
|
|
852
|
+
# @param group_properties [Hash]
|
|
853
|
+
# @param only_evaluate_locally [Boolean] Skip the remote /flags call.
|
|
854
|
+
#
|
|
855
|
+
def get_all_flags_and_payloads(
|
|
856
|
+
distinct_id,
|
|
857
|
+
groups: {},
|
|
858
|
+
person_properties: {},
|
|
859
|
+
group_properties: {},
|
|
860
|
+
only_evaluate_locally: false
|
|
861
|
+
)
|
|
862
|
+
return { featureFlags: {}, featureFlagPayloads: {} } if @disabled
|
|
863
|
+
|
|
864
|
+
person_properties, group_properties = add_local_person_and_group_properties(
|
|
865
|
+
groups, person_properties, group_properties
|
|
866
|
+
)
|
|
867
|
+
response = @feature_flags_poller.get_all_flags_and_payloads(
|
|
868
|
+
distinct_id, groups, person_properties, group_properties, only_evaluate_locally
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# Remove internal information
|
|
872
|
+
response.delete(:requestId)
|
|
873
|
+
response.delete(:evaluatedAt)
|
|
874
|
+
response.delete(:flagDetails)
|
|
875
|
+
response.delete(:minimalFlagCalledEvents)
|
|
876
|
+
response
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Reload locally cached feature flag definitions synchronously on the
|
|
880
|
+
# calling thread.
|
|
881
|
+
#
|
|
882
|
+
# @return [void]
|
|
883
|
+
def reload_feature_flags
|
|
884
|
+
return if @disabled
|
|
885
|
+
|
|
886
|
+
unless @secret_key
|
|
887
|
+
logger.error(
|
|
888
|
+
'You need to specify a secret_key to locally evaluate feature flags'
|
|
889
|
+
)
|
|
890
|
+
return
|
|
891
|
+
end
|
|
892
|
+
@feature_flags_poller.load_feature_flags(true)
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
# Whether feature flag definitions for local evaluation are currently loaded.
|
|
896
|
+
# False until the first successful load, and false again if a quota-limited
|
|
897
|
+
# (402) response discards the definitions.
|
|
898
|
+
#
|
|
899
|
+
# @return [Boolean]
|
|
900
|
+
def feature_flags_loaded?
|
|
901
|
+
return false if @disabled
|
|
902
|
+
|
|
903
|
+
@feature_flags_poller.definitions_loaded?
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# Whether the client will actually send events. It is disabled when the
|
|
907
|
+
# api_key is missing or blank, in which case every capture call no-ops.
|
|
908
|
+
#
|
|
909
|
+
# @return [Boolean]
|
|
910
|
+
def enabled?
|
|
911
|
+
!@disabled
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Flush pending events and stop background resources.
|
|
915
|
+
#
|
|
916
|
+
# When sync_mode is not set, this method calls the flush method; see flush
|
|
917
|
+
# method documentation for timeout semantics. Unlike flush, this method
|
|
918
|
+
# stops the worker. So any events still queued after the timeout will not be
|
|
919
|
+
# sent. After the timeout, this method may wait up to one additional second
|
|
920
|
+
# for worker and transport cleanup, which may continue after it returns.
|
|
921
|
+
#
|
|
922
|
+
# @param timeout [Numeric, nil] Maximum seconds to wait for pending events
|
|
923
|
+
# to be sent, or +nil+ to wait indefinitely. Has no effect when sync_mode
|
|
924
|
+
# is true.
|
|
925
|
+
# @return [Boolean] +true+ if all pending events were sent, +false+ if the
|
|
926
|
+
# timeout elapsed first.
|
|
927
|
+
def shutdown(timeout: nil)
|
|
928
|
+
deadline = timeout && (monotonic_time + timeout)
|
|
929
|
+
shutdown_result = @shutdown_mutex.synchronize do
|
|
930
|
+
if @shutdown
|
|
931
|
+
until @shutdown_complete
|
|
932
|
+
# Avoid deadlocking when an in-flight send's on_error callback
|
|
933
|
+
# re-enters shutdown while the original caller is waiting for it.
|
|
934
|
+
break if Thread.current == @worker_thread || (@sync_mode && @sync_lock.owned?)
|
|
935
|
+
|
|
936
|
+
remaining = deadline && (deadline - monotonic_time)
|
|
937
|
+
break if remaining && remaining <= 0
|
|
938
|
+
|
|
939
|
+
@shutdown_condition.wait(@shutdown_mutex, remaining)
|
|
940
|
+
end
|
|
941
|
+
@shutdown_complete ? @shutdown_result : false
|
|
942
|
+
else
|
|
943
|
+
@shutdown = true
|
|
944
|
+
nil
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
return shutdown_result unless shutdown_result.nil?
|
|
948
|
+
|
|
949
|
+
flushed = false
|
|
950
|
+
begin
|
|
951
|
+
Txlemetry::Client._decrement_instance_count(@api_key) if @instance_registered
|
|
952
|
+
@feature_flags_poller&.shutdown_poller
|
|
953
|
+
flushed =
|
|
954
|
+
if @sync_mode
|
|
955
|
+
# Waiting for @sync_lock lets any in-flight sync send finish before
|
|
956
|
+
# the connection is closed.
|
|
957
|
+
@sync_lock.synchronize { @transport&.shutdown }
|
|
958
|
+
true
|
|
959
|
+
else
|
|
960
|
+
drained = flush(timeout: timeout)
|
|
961
|
+
@worker&.shutdown
|
|
962
|
+
@worker_thread&.join(1)
|
|
963
|
+
drained
|
|
964
|
+
end
|
|
965
|
+
@distinct_id_has_sent_flag_calls_mutex.synchronize do
|
|
966
|
+
@distinct_id_has_sent_flag_calls.clear
|
|
967
|
+
end
|
|
968
|
+
flushed
|
|
969
|
+
ensure
|
|
970
|
+
@shutdown_mutex.synchronize do
|
|
971
|
+
@shutdown_result = flushed
|
|
972
|
+
@shutdown_complete = true
|
|
973
|
+
@shutdown_condition.broadcast
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
private
|
|
979
|
+
|
|
980
|
+
def enrich_capture_attrs_with_context(attrs)
|
|
981
|
+
context = Internal::Context.current
|
|
982
|
+
explicit_properties = attrs[:properties]
|
|
983
|
+
properties_are_hash = explicit_properties.nil? || explicit_properties.is_a?(Hash)
|
|
984
|
+
context_properties = context&.properties || {}
|
|
985
|
+
if properties_are_hash
|
|
986
|
+
attrs[:properties] = Internal::Context.merge_properties(context_properties, explicit_properties || {})
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
return if present_id?(attrs[:distinct_id])
|
|
990
|
+
|
|
991
|
+
if present_id?(context&.distinct_id)
|
|
992
|
+
attrs[:distinct_id] = context.distinct_id
|
|
993
|
+
return
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
attrs[:distinct_id] = SecureRandom.uuid
|
|
997
|
+
return unless properties_are_hash
|
|
998
|
+
return if property_key?(explicit_properties, '$process_person_profile')
|
|
999
|
+
|
|
1000
|
+
attrs[:properties]['$process_person_profile'] = false
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def present_id?(value)
|
|
1004
|
+
!(value.nil? || (value.is_a?(String) && value.empty?))
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
def property_key?(properties, key)
|
|
1008
|
+
return false unless properties.is_a?(Hash)
|
|
1009
|
+
|
|
1010
|
+
properties.key?(key) || properties.key?(key.to_sym)
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# Shared by the legacy single-flag path ({#get_feature_flag_result}) and the
|
|
1014
|
+
# snapshot's access-recording. Owns dedup-key construction, the
|
|
1015
|
+
# per-distinct_id sent-flags cache, and the `$feature_flag_called` capture call.
|
|
1016
|
+
# Group context is included in the dedup key so group-scoped flags fire a
|
|
1017
|
+
# separate event for each group a user is evaluated under.
|
|
1018
|
+
def _capture_feature_flag_called_if_needed(
|
|
1019
|
+
distinct_id: nil, key: nil, response: nil, properties: nil,
|
|
1020
|
+
groups: nil, disable_geoip: nil, minimal: false
|
|
1021
|
+
)
|
|
1022
|
+
response_repr = response.nil? ? '::null::' : response
|
|
1023
|
+
groups_repr =
|
|
1024
|
+
if groups && !groups.empty?
|
|
1025
|
+
# Canonicalize so two equal hashes with keys inserted in a different
|
|
1026
|
+
# order produce the same dedup key.
|
|
1027
|
+
"_#{groups.sort.to_json}"
|
|
1028
|
+
else
|
|
1029
|
+
''
|
|
1030
|
+
end
|
|
1031
|
+
reported_key = "#{key}_#{response_repr}#{groups_repr}"
|
|
1032
|
+
should_capture = @distinct_id_has_sent_flag_calls_mutex.synchronize do
|
|
1033
|
+
sent_keys = @distinct_id_has_sent_flag_calls[distinct_id]
|
|
1034
|
+
if sent_keys.include?(reported_key)
|
|
1035
|
+
false
|
|
1036
|
+
else
|
|
1037
|
+
sent_keys << reported_key
|
|
1038
|
+
true
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
return unless should_capture
|
|
1042
|
+
|
|
1043
|
+
msg = {
|
|
1044
|
+
distinct_id: distinct_id,
|
|
1045
|
+
event: '$feature_flag_called',
|
|
1046
|
+
properties: properties
|
|
1047
|
+
}
|
|
1048
|
+
msg[:groups] = groups if groups
|
|
1049
|
+
msg[:disable_geoip] = disable_geoip unless disable_geoip.nil?
|
|
1050
|
+
msg[:_minimal_flag_called_event] = true if minimal
|
|
1051
|
+
|
|
1052
|
+
capture(msg)
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
def _feature_flag_evaluations_host
|
|
1056
|
+
@feature_flag_evaluations_host ||= FeatureFlagEvaluations::Host.new(
|
|
1057
|
+
capture_flag_called_event_if_needed: method(:_capture_feature_flag_called_if_needed),
|
|
1058
|
+
log_warning: ->(message) { logger.warn(message) }
|
|
1059
|
+
)
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Implementation of {#get_feature_flag_result}, called by both the public
|
|
1063
|
+
# method and the legacy `is_feature_enabled` / `get_feature_flag` paths so
|
|
1064
|
+
# a single user-level call emits exactly one deprecation warning.
|
|
1065
|
+
def _get_feature_flag_result(
|
|
1066
|
+
key,
|
|
1067
|
+
distinct_id,
|
|
1068
|
+
groups: {},
|
|
1069
|
+
person_properties: {},
|
|
1070
|
+
group_properties: {},
|
|
1071
|
+
only_evaluate_locally: false,
|
|
1072
|
+
send_feature_flag_events: true
|
|
1073
|
+
)
|
|
1074
|
+
return nil if @disabled
|
|
1075
|
+
|
|
1076
|
+
key = key.to_s
|
|
1077
|
+
person_properties, group_properties = add_local_person_and_group_properties(
|
|
1078
|
+
groups, person_properties, group_properties
|
|
1079
|
+
)
|
|
1080
|
+
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload,
|
|
1081
|
+
has_experiment, minimal_flag_called_events =
|
|
1082
|
+
@feature_flags_poller.get_feature_flag(
|
|
1083
|
+
key, distinct_id, groups, person_properties, group_properties, only_evaluate_locally
|
|
1084
|
+
)
|
|
1085
|
+
if send_feature_flag_events
|
|
1086
|
+
properties = {
|
|
1087
|
+
'$feature_flag' => key,
|
|
1088
|
+
'$feature_flag_response' => feature_flag_response,
|
|
1089
|
+
'locally_evaluated' => flag_was_locally_evaluated
|
|
1090
|
+
}
|
|
1091
|
+
properties['$feature_flag_has_experiment'] = has_experiment unless has_experiment.nil?
|
|
1092
|
+
properties['$feature_flag_request_id'] = request_id if request_id
|
|
1093
|
+
properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
|
|
1094
|
+
properties['$feature_flag_error'] = feature_flag_error if feature_flag_error
|
|
1095
|
+
|
|
1096
|
+
# Emit a minimal event only when the server gate is on and the flag is
|
|
1097
|
+
# known to have no linked experiment. Any missing signal fails safe to
|
|
1098
|
+
# the full event.
|
|
1099
|
+
minimal = minimal_flag_called_events == true && has_experiment == false
|
|
1100
|
+
_capture_feature_flag_called_if_needed(
|
|
1101
|
+
distinct_id: distinct_id, key: key, response: feature_flag_response,
|
|
1102
|
+
properties: properties, groups: groups, minimal: minimal
|
|
1103
|
+
)
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload)
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
# Emits a deprecation warning at most once per `(method_name, process)` pair.
|
|
1110
|
+
# Ruby's `Kernel.warn(..., category: :deprecated)` is suppressed by default
|
|
1111
|
+
# since 2.7.2; we emit without the category so messages reach users on a
|
|
1112
|
+
# default Ruby setup. Standard logger configuration / `$VERBOSE = nil` / IO
|
|
1113
|
+
# redirection still silences as expected.
|
|
1114
|
+
def _emit_deprecation(method_name, message)
|
|
1115
|
+
return unless @deprecation_emitted_for.add?(method_name)
|
|
1116
|
+
|
|
1117
|
+
Kernel.warn("[txlemetry-ruby] DEPRECATION: #{message}", uplevel: 2)
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# before_send should run immediately before the event is sent to the queue.
|
|
1121
|
+
# @param [Object] action The event to be sent to Txlemetry
|
|
1122
|
+
# @return [null, Object, nil] The processed event or nil if the event should not be sent
|
|
1123
|
+
def process_before_send(action)
|
|
1124
|
+
return action if action.nil? || action.empty?
|
|
1125
|
+
return action unless @before_send
|
|
1126
|
+
|
|
1127
|
+
begin
|
|
1128
|
+
processed_action = @before_send.call(action)
|
|
1129
|
+
|
|
1130
|
+
if processed_action.nil?
|
|
1131
|
+
logger.warn("Event #{action[:event]} was rejected in beforeSend function")
|
|
1132
|
+
elsif processed_action.empty?
|
|
1133
|
+
logger.warn("Event #{action[:event]} has no properties after beforeSend function, this is likely an error")
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
processed_action
|
|
1137
|
+
rescue StandardError => e
|
|
1138
|
+
logger.error("Error in beforeSend function - dropping event: #{e.message}")
|
|
1139
|
+
nil
|
|
1140
|
+
end
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
# private: Enqueues the action.
|
|
1144
|
+
#
|
|
1145
|
+
# returns Boolean of whether the item was added to the queue.
|
|
1146
|
+
def enqueue(action)
|
|
1147
|
+
return false if @disabled || shutdown?
|
|
1148
|
+
|
|
1149
|
+
# Every event type passes through here, and before_send still sees the id.
|
|
1150
|
+
action = add_release_id(action)
|
|
1151
|
+
action = process_before_send(action)
|
|
1152
|
+
return false if action.nil? || action.empty?
|
|
1153
|
+
|
|
1154
|
+
if @sync_mode
|
|
1155
|
+
send_sync(action)
|
|
1156
|
+
return true
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
queued = @queue_mutex.synchronize do
|
|
1160
|
+
if @queue.length < @max_queue_size
|
|
1161
|
+
@queue << action
|
|
1162
|
+
true
|
|
1163
|
+
else
|
|
1164
|
+
false
|
|
1165
|
+
end
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
if queued
|
|
1169
|
+
ensure_worker_running
|
|
1170
|
+
@worker.notify
|
|
1171
|
+
true
|
|
1172
|
+
else
|
|
1173
|
+
logger.warn(
|
|
1174
|
+
'Queue is full, dropping events. The :max_queue_size ' \
|
|
1175
|
+
'configuration parameter can be increased to prevent this from ' \
|
|
1176
|
+
'happening.'
|
|
1177
|
+
)
|
|
1178
|
+
false
|
|
1179
|
+
end
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
# A `$release_id` the caller or the request context already set wins.
|
|
1183
|
+
def add_release_id(action)
|
|
1184
|
+
return action if @release_id.nil?
|
|
1185
|
+
|
|
1186
|
+
properties = action[:properties]
|
|
1187
|
+
return action unless properties.is_a?(Hash)
|
|
1188
|
+
return action if properties.key?('$release_id') || properties.key?(:$release_id)
|
|
1189
|
+
|
|
1190
|
+
action.merge(properties: properties.merge('$release_id' => @release_id))
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
def normalize_string_option(value, blank_as_nil: false)
|
|
1194
|
+
return value unless value.is_a?(String)
|
|
1195
|
+
|
|
1196
|
+
normalized = value.strip
|
|
1197
|
+
return nil if blank_as_nil && normalized.empty?
|
|
1198
|
+
|
|
1199
|
+
normalized
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
def normalize_host_option(host)
|
|
1203
|
+
normalized = normalize_string_option(host)
|
|
1204
|
+
return 'https://app.txlemetry.com' if normalized.nil? || normalized.empty?
|
|
1205
|
+
|
|
1206
|
+
normalized
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
def ensure_worker_running
|
|
1210
|
+
return if worker_running?
|
|
1211
|
+
|
|
1212
|
+
@worker_mutex.synchronize do
|
|
1213
|
+
return if worker_running?
|
|
1214
|
+
|
|
1215
|
+
@worker_thread = Thread.new { @worker.run }
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
def send_sync(action)
|
|
1220
|
+
batch = MessageBatch.new(1)
|
|
1221
|
+
begin
|
|
1222
|
+
batch << action
|
|
1223
|
+
rescue MessageBatch::JSONGenerationError => e
|
|
1224
|
+
@on_error.call(-1, e.to_s)
|
|
1225
|
+
return
|
|
1226
|
+
end
|
|
1227
|
+
return if batch.empty?
|
|
1228
|
+
|
|
1229
|
+
@sync_lock.synchronize do
|
|
1230
|
+
res = @transport.send(@api_key, batch)
|
|
1231
|
+
@on_error.call(res.status, res.error) unless res.status.between?(200, 299)
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def worker_running?
|
|
1236
|
+
@worker_thread&.alive?
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
def shutdown?
|
|
1240
|
+
@shutdown_mutex.synchronize { @shutdown }
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
def add_local_person_and_group_properties(groups, person_properties, group_properties)
|
|
1244
|
+
groups ||= {}
|
|
1245
|
+
person_properties ||= {}
|
|
1246
|
+
group_properties ||= {}
|
|
1247
|
+
|
|
1248
|
+
symbolize_keys! groups
|
|
1249
|
+
symbolize_keys! person_properties
|
|
1250
|
+
symbolize_keys! group_properties
|
|
1251
|
+
|
|
1252
|
+
group_properties.each_value do |value|
|
|
1253
|
+
symbolize_keys! value
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
all_group_properties = {}
|
|
1257
|
+
groups&.each do |group_name, group_key|
|
|
1258
|
+
all_group_properties[group_name] = {
|
|
1259
|
+
'$group_key': group_key
|
|
1260
|
+
}.merge((group_properties && group_properties[group_name]) || {})
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
[person_properties, all_group_properties]
|
|
1264
|
+
end
|
|
1265
|
+
end
|
|
1266
|
+
end
|