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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +48 -0
  3. data/NOTICE +5 -0
  4. data/bin/txlemetry +110 -0
  5. data/lib/txlemetry/backoff_policy.rb +49 -0
  6. data/lib/txlemetry/client.rb +1266 -0
  7. data/lib/txlemetry/defaults.rb +50 -0
  8. data/lib/txlemetry/exception_capture.rb +246 -0
  9. data/lib/txlemetry/feature_flag.rb +85 -0
  10. data/lib/txlemetry/feature_flag_error.rb +37 -0
  11. data/lib/txlemetry/feature_flag_evaluations.rb +237 -0
  12. data/lib/txlemetry/feature_flag_result.rb +81 -0
  13. data/lib/txlemetry/feature_flags.rb +1516 -0
  14. data/lib/txlemetry/field_parser.rb +219 -0
  15. data/lib/txlemetry/flag_definition_cache.rb +88 -0
  16. data/lib/txlemetry/internal/context.rb +119 -0
  17. data/lib/txlemetry/logging.rb +77 -0
  18. data/lib/txlemetry/mcp/README.md +53 -0
  19. data/lib/txlemetry/mcp/analytics.rb +116 -0
  20. data/lib/txlemetry/mcp/client.rb +257 -0
  21. data/lib/txlemetry/mcp/constants.rb +111 -0
  22. data/lib/txlemetry/mcp/conversation_id.rb +89 -0
  23. data/lib/txlemetry/mcp/event_builder.rb +168 -0
  24. data/lib/txlemetry/mcp/exceptions.rb +112 -0
  25. data/lib/txlemetry/mcp/identity.rb +194 -0
  26. data/lib/txlemetry/mcp/ids.rb +56 -0
  27. data/lib/txlemetry/mcp/instrumentation.rb +736 -0
  28. data/lib/txlemetry/mcp/intent.rb +101 -0
  29. data/lib/txlemetry/mcp/log.rb +37 -0
  30. data/lib/txlemetry/mcp/options.rb +128 -0
  31. data/lib/txlemetry/mcp/rack_middleware.rb +112 -0
  32. data/lib/txlemetry/mcp/request_scope.rb +72 -0
  33. data/lib/txlemetry/mcp/sanitization.rb +504 -0
  34. data/lib/txlemetry/mcp/schema_mutation.rb +179 -0
  35. data/lib/txlemetry/mcp/server_extension.rb +54 -0
  36. data/lib/txlemetry/mcp/session.rb +71 -0
  37. data/lib/txlemetry/mcp/session_token.rb +106 -0
  38. data/lib/txlemetry/mcp/sink.rb +98 -0
  39. data/lib/txlemetry/mcp/tools.rb +91 -0
  40. data/lib/txlemetry/mcp/tracking_data.rb +89 -0
  41. data/lib/txlemetry/mcp/truncation.rb +326 -0
  42. data/lib/txlemetry/mcp.rb +224 -0
  43. data/lib/txlemetry/message_batch.rb +80 -0
  44. data/lib/txlemetry/noop_worker.rb +39 -0
  45. data/lib/txlemetry/response.rb +17 -0
  46. data/lib/txlemetry/send_feature_flags_options.rb +49 -0
  47. data/lib/txlemetry/send_worker.rb +218 -0
  48. data/lib/txlemetry/transport.rb +216 -0
  49. data/lib/txlemetry/utils.rb +183 -0
  50. data/lib/txlemetry/version.rb +5 -0
  51. data/lib/txlemetry.rb +16 -0
  52. 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