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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ module Defaults
5
+ MAX_HASH_SIZE = 50_000
6
+
7
+ module Request
8
+ HOST = 'app.txlemetry.com'
9
+ PORT = 443
10
+ PATH = '/batch/'
11
+ SSL = true
12
+ HEADERS = {
13
+ 'Accept' => 'application/json',
14
+ 'Content-Type' => 'application/json',
15
+ 'User-Agent' => "txlemetry-ruby/#{Txlemetry::VERSION}"
16
+ }.freeze
17
+ RETRIES = 10
18
+ end
19
+
20
+ module FeatureFlags
21
+ FLAG_REQUEST_TIMEOUT_SECONDS = 3
22
+ POLLING_INTERVAL_SECONDS = 30
23
+ # Number of retries for a flag request after a transient network error.
24
+ # Flag requests are stateless and cause no server-side mutation, so
25
+ # retrying is safe.
26
+ FLAG_REQUEST_MAX_RETRIES = 1
27
+ end
28
+
29
+ module Queue
30
+ MAX_SIZE = 10_000
31
+ end
32
+
33
+ module Message
34
+ MAX_BYTES = 32_768 # 32Kb
35
+ end
36
+
37
+ module MessageBatch
38
+ MAX_BYTES = 512_000 # 500Kb
39
+ MAX_SIZE = 100
40
+ FLUSH_INTERVAL_SECONDS = 5.0 # seconds
41
+ end
42
+
43
+ module BackoffPolicy
44
+ MIN_TIMEOUT_MS = 100
45
+ MAX_TIMEOUT_MS = 10_000
46
+ MULTIPLIER = 1.5
47
+ RANDOMIZATION_FACTOR = 0.5
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Portions of this file are derived from getsentry/sentry-ruby
4
+ # Copyright (c) 2020 Sentry
5
+ # Licensed under the MIT License: https://github.com/getsentry/sentry-ruby/blob/master/LICENSE
6
+ # - sentry-ruby/lib/sentry/interfaces/single_exception.rb
7
+ # - sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb
8
+ # - sentry-ruby/lib/sentry/backtrace.rb
9
+ # - sentry-ruby/lib/sentry/interfaces/stacktrace.rb
10
+ # - sentry-ruby/lib/sentry/linecache.rb
11
+
12
+ # 💖 open source (under MIT License)
13
+
14
+ module Txlemetry
15
+ # Builds Txlemetry exception payloads from Ruby exception objects.
16
+ #
17
+ # @api private
18
+ module ExceptionCapture
19
+ RUBY_INPUT_FORMAT = /
20
+ ^ \s* (?: [a-zA-Z]: | uri:classloader: )? ([^:]+ | <.*>):
21
+ (\d+)
22
+ (?: :in\s('|`)(?:([\w:]+)\#)?([^']+)')?$
23
+ /x
24
+
25
+ # Maximum number of exceptions extracted from a single `cause` chain.
26
+ MAX_CHAINED_EXCEPTIONS = 50
27
+
28
+ DEFAULT_MECHANISM = { 'type' => 'generic', 'handled' => true }.freeze
29
+
30
+ # Builds the `$exception_list` payload for an exception, walking its
31
+ # `cause` chain outermost-first (wrapper first, root cause last).
32
+ #
33
+ # @param value [Exception, String, Object] Exception input to parse.
34
+ # @param mechanism [Hash, nil] Mechanism applied to the outermost exception,
35
+ # e.g. `{ 'type' => 'rails', 'handled' => false }`. Chained causes are
36
+ # tagged with `{ 'type' => 'chained', ... }` and parent linkage.
37
+ # @return [Array<Hash>, nil] Parsed exception payloads, or nil when the input is unsupported.
38
+ def self.build_exception_list(value, mechanism: nil)
39
+ root_mechanism = DEFAULT_MECHANISM.merge(mechanism || {})
40
+
41
+ exceptions = []
42
+ seen = {}.compare_by_identity
43
+ current = value
44
+
45
+ while current && exceptions.length < MAX_CHAINED_EXCEPTIONS && !seen.key?(current)
46
+ parsed = build_parsed_exception(current, mechanism: chain_mechanism(root_mechanism, exceptions.length))
47
+ break if parsed.nil?
48
+
49
+ exceptions << parsed
50
+ seen[current] = true
51
+ current = current.respond_to?(:cause) ? current.cause : nil
52
+ end
53
+
54
+ exceptions.empty? ? nil : exceptions
55
+ end
56
+
57
+ # @param root_mechanism [Hash] Mechanism of the outermost exception.
58
+ # @param exception_id [Integer] Zero-based position in the cause chain.
59
+ # @return [Hash]
60
+ def self.chain_mechanism(root_mechanism, exception_id)
61
+ mechanism = root_mechanism.merge('exception_id' => exception_id)
62
+ return mechanism if exception_id.zero?
63
+
64
+ mechanism.merge(
65
+ 'type' => 'chained',
66
+ 'source' => 'cause',
67
+ 'parent_id' => exception_id - 1
68
+ )
69
+ end
70
+
71
+ # @param value [Exception, String, Object] Exception input to parse.
72
+ # @param mechanism [Hash, nil] Mechanism describing how the exception was captured.
73
+ # @return [Hash, nil] Parsed exception payload, or nil when the input is unsupported.
74
+ def self.build_parsed_exception(value, mechanism: nil)
75
+ title, message, backtrace = coerce_exception_input(value)
76
+ return nil if title.nil?
77
+
78
+ build_single_exception_from_data(title, message, backtrace, mechanism: mechanism)
79
+ end
80
+
81
+ # @param title [String]
82
+ # @param message [String, nil]
83
+ # @param backtrace [Array<String>, nil]
84
+ # @param mechanism [Hash, nil]
85
+ # @return [Hash]
86
+ def self.build_single_exception_from_data(title, message, backtrace, mechanism: nil)
87
+ {
88
+ 'type' => title,
89
+ 'value' => message || '',
90
+ 'mechanism' => DEFAULT_MECHANISM.merge(mechanism || {}),
91
+ 'stacktrace' => build_stacktrace(backtrace)
92
+ }
93
+ end
94
+
95
+ # @param backtrace [Array<String>, nil]
96
+ # @return [Hash, nil]
97
+ def self.build_stacktrace(backtrace)
98
+ return nil unless backtrace && !backtrace.empty?
99
+
100
+ root = project_root
101
+ roots = dependency_roots(root)
102
+ frames = backtrace.first(50).map do |line|
103
+ parse_backtrace_line(line, project_root: root, dependency_roots: roots)
104
+ end.compact.reverse
105
+
106
+ {
107
+ 'type' => 'raw',
108
+ 'frames' => frames
109
+ }
110
+ end
111
+
112
+ # @param line [String]
113
+ # @param project_root [String, nil] Project root used to derive project-relative filenames.
114
+ # @param dependency_roots [Array<String>, nil] Cached gem and stdlib roots.
115
+ # @return [Hash, nil]
116
+ def self.parse_backtrace_line(line, project_root: self.project_root, dependency_roots: nil)
117
+ match = line.match(RUBY_INPUT_FORMAT)
118
+ return nil unless match
119
+
120
+ file = match[1]
121
+ lineno = match[2].to_i
122
+ method_name = match[5]
123
+
124
+ frame = {
125
+ 'filename' => frame_filename(file, project_root),
126
+ 'abs_path' => file,
127
+ 'lineno' => lineno,
128
+ 'function' => method_name,
129
+ 'in_app' => !gem_path?(file, dependency_roots || self.dependency_roots(project_root)),
130
+ 'platform' => 'ruby'
131
+ }
132
+
133
+ add_context_lines(frame, file, lineno) if frame['in_app'] && File.exist?(file)
134
+
135
+ frame
136
+ end
137
+
138
+ # Root directory the application runs from, used to derive stable
139
+ # project-relative filenames from per-host deploy paths
140
+ # (e.g. `/app/releases/20240101/...`).
141
+ #
142
+ # @return [String]
143
+ def self.project_root
144
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
145
+ ::Rails.root.to_s
146
+ else
147
+ Dir.pwd
148
+ end
149
+ rescue StandardError
150
+ Dir.pwd
151
+ end
152
+
153
+ # Stable filename used for fingerprinting: the project-relative path when
154
+ # the file lives inside the project root, its basename otherwise. The raw
155
+ # absolute path is kept separately in `abs_path`.
156
+ #
157
+ # @param path [String]
158
+ # @param project_root [String, nil]
159
+ # @return [String]
160
+ def self.frame_filename(path, project_root)
161
+ if project_root && !project_root.empty? && path_within?(path, project_root)
162
+ relative = path[project_root.length..]
163
+ relative = relative[1..] while relative.start_with?(File::SEPARATOR)
164
+ return relative unless relative.empty?
165
+ end
166
+
167
+ File.basename(path)
168
+ end
169
+
170
+ # Whether the path belongs to an installed gem or the Ruby standard library,
171
+ # based on gem install locations rather than path substrings.
172
+ #
173
+ # @param path [String]
174
+ # @param dependency_roots [Array<String>]
175
+ # @return [Boolean]
176
+ def self.gem_path?(path, dependency_roots = self.dependency_roots)
177
+ dependency_roots.any? { |root| path_within?(path, root) }
178
+ end
179
+
180
+ # @param project_root [String]
181
+ # @return [Array<String>] Directories containing installed gems and the Ruby stdlib.
182
+ def self.dependency_roots(project_root = self.project_root)
183
+ roots = []
184
+ if defined?(Gem)
185
+ roots.concat(Gem.path) if Gem.respond_to?(:path)
186
+ roots << Gem.default_dir if Gem.respond_to?(:default_dir)
187
+ roots.concat(Gem.loaded_specs.each_value.map(&:full_gem_path)) if Gem.respond_to?(:loaded_specs)
188
+ end
189
+ roots << RbConfig::CONFIG['rubylibprefix'] if defined?(RbConfig)
190
+ # The project itself can be a loaded spec (e.g. a gem developed in place,
191
+ # or a Rails engine); its frames are still application code.
192
+ roots.compact.reject(&:empty?).uniq - [project_root]
193
+ rescue StandardError
194
+ []
195
+ end
196
+
197
+ # @param path [String]
198
+ # @param root [String]
199
+ # @return [Boolean]
200
+ def self.path_within?(path, root)
201
+ root = root.chomp(File::SEPARATOR)
202
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
203
+ end
204
+
205
+ # @param frame [Hash]
206
+ # @param file_path [String]
207
+ # @param lineno [Integer]
208
+ # @param context_size [Integer]
209
+ # @return [void]
210
+ def self.add_context_lines(frame, file_path, lineno, context_size = 5)
211
+ lines = File.readlines(file_path)
212
+ return if lines.empty?
213
+
214
+ return unless lineno.positive? && lineno <= lines.length
215
+
216
+ pre_context_start = [lineno - context_size, 1].max
217
+ post_context_end = [lineno + context_size, lines.length].min
218
+
219
+ frame['context_line'] = lines[lineno - 1].chomp
220
+
221
+ frame['pre_context'] = lines[(pre_context_start - 1)...(lineno - 1)].map(&:chomp) if pre_context_start < lineno
222
+
223
+ frame['post_context'] = lines[lineno...(post_context_end)].map(&:chomp) if post_context_end > lineno
224
+ rescue StandardError
225
+ # Silently ignore file read errors
226
+ end
227
+
228
+ # @param value [Exception, String, Object]
229
+ # @return [Array] Three-item array of title, message, and backtrace.
230
+ def self.coerce_exception_input(value)
231
+ if value.is_a?(String)
232
+ title = 'Error'
233
+ message = value
234
+ backtrace = nil
235
+ elsif value.respond_to?(:backtrace) && value.respond_to?(:message)
236
+ title = value.class.to_s
237
+ message = value.message || ''
238
+ backtrace = value.backtrace
239
+ else
240
+ return [nil, nil, nil]
241
+ end
242
+
243
+ [title, message, backtrace]
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ # Represents a feature flag returned by /flags v2.
5
+ #
6
+ # @api private
7
+ class FeatureFlag
8
+ attr_reader :key, :enabled, :variant, :reason, :metadata, :failed
9
+
10
+ # @param json [Hash] Raw feature flag data returned by /flags.
11
+ def initialize(json)
12
+ json.transform_keys!(&:to_s)
13
+ @key = json['key']
14
+ @enabled = json['enabled']
15
+ @variant = json['variant']
16
+ @reason = json['reason'] ? EvaluationReason.new(json['reason']) : nil
17
+ @metadata = json['metadata'] ? FeatureFlagMetadata.new(json['metadata'].transform_keys(&:to_s)) : nil
18
+ @failed = json['failed']
19
+ end
20
+
21
+ # @return [String, Boolean] The variant value when present, otherwise the enabled status.
22
+ # TODO: Rename to `value` in future version
23
+ def get_value # rubocop:disable Naming/AccessorMethodName
24
+ @variant || @enabled
25
+ end
26
+
27
+ # @return [Object, nil] The flag payload from metadata.
28
+ def payload
29
+ @metadata&.payload
30
+ end
31
+
32
+ # @param key [String, Symbol] The feature flag key.
33
+ # @param value [String, Boolean] The feature flag value.
34
+ # @param payload [Object, nil] The feature flag payload.
35
+ # @return [Txlemetry::FeatureFlag]
36
+ def self.from_value_and_payload(key, value, payload)
37
+ new({
38
+ 'key' => key,
39
+ 'enabled' => value.is_a?(String) || value,
40
+ 'variant' => value.is_a?(String) ? value : nil,
41
+ 'reason' => nil,
42
+ 'metadata' => {
43
+ 'id' => nil,
44
+ 'version' => nil,
45
+ 'payload' => payload,
46
+ 'description' => nil
47
+ }
48
+ })
49
+ end
50
+ end
51
+
52
+ # Represents the reason why a flag was enabled/disabled.
53
+ #
54
+ # @api private
55
+ class EvaluationReason
56
+ attr_reader :code, :description, :condition_index
57
+
58
+ # @param json [Hash] Raw reason data returned by /flags.
59
+ def initialize(json)
60
+ json.transform_keys!(&:to_s)
61
+ @code = json['code']
62
+ @description = json['description']
63
+ @condition_index = json['condition_index'].to_i if json['condition_index']
64
+ end
65
+ end
66
+
67
+ # Represents metadata about a feature flag.
68
+ #
69
+ # @api private
70
+ class FeatureFlagMetadata
71
+ attr_reader :id, :version, :payload, :description, :has_experiment
72
+
73
+ # @param json [Hash] Raw metadata returned by /flags.
74
+ def initialize(json)
75
+ json.transform_keys!(&:to_s)
76
+ @id = json['id']
77
+ @version = json['version']
78
+ @payload = json['payload']
79
+ @description = json['description']
80
+ # Whether the flag is linked to an experiment. nil when the server
81
+ # (an older deployment) does not report the field.
82
+ @has_experiment = json['has_experiment']
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ # Error type constants for the $feature_flag_error property.
5
+ #
6
+ # These values are sent in analytics events to track flag evaluation failures.
7
+ # They should not be changed without considering impact on existing dashboards
8
+ # and queries that filter on these values.
9
+ #
10
+ # Error values:
11
+ # ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
12
+ # FLAG_MISSING: Requested flag not in API response
13
+ # QUOTA_LIMITED: Rate/quota limit exceeded
14
+ # TIMEOUT: Request timed out
15
+ # CONNECTION_ERROR: Network connectivity issue
16
+ # UNKNOWN_ERROR: Unexpected exceptions
17
+ #
18
+ # For API errors with status codes, use the api_error() method which returns
19
+ # a string like "api_error_500".
20
+ # @api private
21
+ class FeatureFlagError
22
+ ERRORS_WHILE_COMPUTING = 'errors_while_computing_flags'
23
+ FLAG_MISSING = 'flag_missing'
24
+ QUOTA_LIMITED = 'quota_limited'
25
+ TIMEOUT = 'timeout'
26
+ CONNECTION_ERROR = 'connection_error'
27
+ UNKNOWN_ERROR = 'unknown_error'
28
+
29
+ # Generate API error string with status code.
30
+ #
31
+ # @param status [Integer, String] The HTTP status code
32
+ # @return [String] Error string in format "api_error_STATUS"
33
+ def self.api_error(status)
34
+ "api_error_#{status.to_s.downcase}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Txlemetry
6
+ # A snapshot of feature flag evaluations for one distinct_id, returned by
7
+ # Txlemetry::Client#evaluate_flags. Calls to {#enabled?} / {#get_flag} fire the
8
+ # `$feature_flag_called` event (deduped through the existing per-distinct_id
9
+ # cache); {#get_flag_payload} does not. Pass the snapshot to `capture(flags:)`
10
+ # to attach `$feature/<key>` and `$active_feature_flags` without a second
11
+ # /flags request.
12
+ class FeatureFlagEvaluations
13
+ EVALUATED_LOCALLY_REASON = 'Evaluated locally'
14
+
15
+ EvaluatedFlagRecord = Struct.new(
16
+ :key, :enabled, :variant, :payload, :id, :version, :reason, :locally_evaluated, :has_experiment,
17
+ keyword_init: true
18
+ )
19
+
20
+ Host = Struct.new(:capture_flag_called_event_if_needed, :log_warning, keyword_init: true)
21
+
22
+ # @return [String] The distinct id these evaluations belong to.
23
+ attr_reader :distinct_id
24
+
25
+ # @return [Hash, nil] Group analytics mapping used for evaluation.
26
+ attr_reader :groups
27
+
28
+ # @return [String, nil] Request id returned by the /flags endpoint.
29
+ attr_reader :request_id
30
+
31
+ # @return [String, nil] Evaluation timestamp returned by the /flags endpoint.
32
+ attr_reader :evaluated_at
33
+
34
+ # @return [Time, nil] When local flag definitions were loaded.
35
+ attr_reader :flag_definitions_loaded_at
36
+
37
+ # @param host [Host, nil] Internal host callbacks used to record flag access events.
38
+ # @param distinct_id [String, nil] The distinct id these evaluations belong to.
39
+ # @param flags [Hash] Evaluated flags keyed by flag key.
40
+ # @param groups [Hash, nil] Group analytics mapping from group type to group key.
41
+ # @param disable_geoip [Boolean, nil] Whether GeoIP was disabled during evaluation.
42
+ # @param request_id [String, nil] The request id returned by the /flags endpoint.
43
+ # @param evaluated_at [String, nil] The evaluation timestamp returned by the /flags endpoint.
44
+ # @param flag_definitions_loaded_at [Time, nil] When local flag definitions were loaded.
45
+ # @param errors_while_computing [Boolean] Whether the server reported errors while computing flags.
46
+ # @param quota_limited [Boolean] Whether feature flag evaluation was quota limited.
47
+ # @param minimal_flag_called_events [Boolean] Server-controlled gate for minimal
48
+ # `$feature_flag_called` events.
49
+ # @param accessed [Array<String>, Set<String>, nil] Flag keys already accessed by this snapshot.
50
+ def initialize(
51
+ host: nil,
52
+ distinct_id: nil,
53
+ flags: {},
54
+ groups: nil,
55
+ disable_geoip: nil,
56
+ request_id: nil,
57
+ evaluated_at: nil,
58
+ flag_definitions_loaded_at: nil,
59
+ errors_while_computing: false,
60
+ quota_limited: false,
61
+ minimal_flag_called_events: false,
62
+ accessed: nil
63
+ )
64
+ @host = host
65
+ @distinct_id = distinct_id || ''
66
+ @flags = flags || {}
67
+ @groups = groups
68
+ @disable_geoip = disable_geoip
69
+ @request_id = request_id
70
+ @evaluated_at = evaluated_at
71
+ @flag_definitions_loaded_at = flag_definitions_loaded_at
72
+ @errors_while_computing = errors_while_computing
73
+ @quota_limited = quota_limited
74
+ @minimal_flag_called_events = minimal_flag_called_events == true
75
+ @accessed = Set.new(accessed || [])
76
+ end
77
+
78
+ # @return [Array<String>] The evaluated flag keys in this snapshot.
79
+ def keys
80
+ @flags.keys
81
+ end
82
+
83
+ # @param key [String, Symbol] The feature flag key.
84
+ # @param default_value [Boolean, nil] Returned when the flag has no value in this
85
+ # snapshot — it was never loaded, the `/flags` request failed, or no flag with that
86
+ # key exists. A flag that does have a value, including `false` and variant strings,
87
+ # always wins over this default; so does a flag the server returned but marked as
88
+ # failed, which resolves to `false`. Defaults to `nil`, which keeps the historical
89
+ # `false` result for a missing flag.
90
+ # @return [Boolean] true when the flag is enabled, false when disabled, and the
91
+ # caller-supplied default (or false when none was supplied) when the flag is missing.
92
+ def enabled?(key, default_value: nil)
93
+ key = key.to_s
94
+ flag = @flags[key]
95
+ _record_access(key, flag)
96
+ return !!default_value if flag.nil? && !default_value.nil?
97
+
98
+ flag&.enabled ? true : false
99
+ end
100
+
101
+ # @param key [String, Symbol] The feature flag key.
102
+ # @return [String, Boolean, nil] Variant string for multivariate flags, true/false for boolean flags,
103
+ # or nil when the flag was not returned by the evaluation.
104
+ def get_flag(key)
105
+ key = key.to_s
106
+ flag = @flags[key]
107
+ _record_access(key, flag)
108
+ _flag_value(flag)
109
+ end
110
+
111
+ # @param key [String, Symbol] The feature flag key.
112
+ # @return [Object, nil] The parsed payload for the flag, if any.
113
+ def get_flag_payload(key)
114
+ flag = @flags[key.to_s]
115
+ flag&.payload
116
+ end
117
+
118
+ # Order-dependent: if nothing has been accessed yet, the returned snapshot is
119
+ # empty. The method honors its name — pre-access flags before calling this if
120
+ # you want a populated result.
121
+ # @return [Txlemetry::FeatureFlagEvaluations] A snapshot containing only flags already accessed with
122
+ # {#enabled?} or {#get_flag}.
123
+ def only_accessed
124
+ _clone_with(@flags.slice(*@accessed))
125
+ end
126
+
127
+ # @param keys [Array<String, Symbol>, String, Symbol] Flag keys to keep in the returned snapshot.
128
+ # @return [Txlemetry::FeatureFlagEvaluations] A snapshot containing only the requested keys that exist.
129
+ def only(keys)
130
+ keys = Array(keys).map(&:to_s)
131
+ missing = keys.reject { |k| @flags.key?(k) }
132
+ unless missing.empty?
133
+ @host.log_warning.call(
134
+ 'FeatureFlagEvaluations#only was called with flag keys that are not in the ' \
135
+ "evaluation set and will be dropped: #{missing.join(', ')}"
136
+ )
137
+ end
138
+ filtered = @flags.slice(*keys)
139
+ _clone_with(filtered)
140
+ end
141
+
142
+ # Builds the `$feature/<key>` and `$active_feature_flags` properties for a
143
+ # captured event. Called from Txlemetry::Client#capture when `flags:` is set.
144
+ #
145
+ # @api private
146
+ # @return [Hash]
147
+ def _get_event_properties
148
+ properties = {}
149
+ active = []
150
+ @flags.each do |key, flag|
151
+ properties["$feature/#{key}"] = flag.enabled ? (flag.variant || true) : false
152
+ active << key if flag.enabled
153
+ end
154
+ properties['$active_feature_flags'] = active.sort unless active.empty?
155
+ properties
156
+ end
157
+
158
+ private
159
+
160
+ # Canonical "stored" value for a flag — used for both the
161
+ # `$feature_flag_response` event property and the dedup cache key, so
162
+ # `enabled?` and `get_flag` collapse to a single exposure per flag.
163
+ # Variant string when present, else boolean enabled status; `nil` for
164
+ # unknown flags.
165
+ def _flag_value(flag)
166
+ return nil if flag.nil?
167
+ return flag.variant if flag.variant
168
+
169
+ flag.enabled ? true : false
170
+ end
171
+
172
+ def _record_access(key, flag)
173
+ @accessed.add(key)
174
+ return if @distinct_id.nil? || @distinct_id.to_s.empty?
175
+
176
+ response = _flag_value(flag)
177
+ properties = {
178
+ '$feature_flag' => key,
179
+ '$feature_flag_response' => response,
180
+ 'locally_evaluated' => flag&.locally_evaluated ? true : false,
181
+ "$feature/#{key}" => response
182
+ }
183
+
184
+ if flag
185
+ properties['$feature_flag_has_experiment'] = flag.has_experiment unless flag.has_experiment.nil?
186
+ properties['$feature_flag_payload'] = flag.payload unless flag.payload.nil?
187
+ properties['$feature_flag_id'] = flag.id if flag.id
188
+ properties['$feature_flag_version'] = flag.version if flag.version
189
+ properties['$feature_flag_reason'] = flag.reason if flag.reason
190
+ if flag.locally_evaluated && @flag_definitions_loaded_at
191
+ properties['$feature_flag_definitions_loaded_at'] = @flag_definitions_loaded_at
192
+ end
193
+ end
194
+
195
+ properties['$feature_flag_request_id'] = @request_id if @request_id
196
+ properties['$feature_flag_evaluated_at'] = @evaluated_at if @evaluated_at && !(flag && flag.locally_evaluated)
197
+
198
+ errors = []
199
+ errors << 'errors_while_computing_flags' if @errors_while_computing
200
+ errors << 'quota_limited' if @quota_limited
201
+ errors << 'flag_missing' if flag.nil?
202
+ properties['$feature_flag_error'] = errors.join(',') unless errors.empty?
203
+
204
+ # Emit a minimal event only when the server gate is on and the flag is
205
+ # known to have no linked experiment. Any missing signal (unknown flag,
206
+ # has_experiment absent) fails safe to the full event.
207
+ minimal = @minimal_flag_called_events && !flag.nil? && flag.has_experiment == false
208
+
209
+ @host.capture_flag_called_event_if_needed.call(
210
+ distinct_id: @distinct_id,
211
+ key: key,
212
+ response: response,
213
+ properties: properties,
214
+ groups: @groups,
215
+ disable_geoip: @disable_geoip,
216
+ minimal: minimal
217
+ )
218
+ end
219
+
220
+ def _clone_with(flags)
221
+ self.class.new(
222
+ host: @host,
223
+ distinct_id: @distinct_id,
224
+ flags: flags,
225
+ groups: @groups,
226
+ disable_geoip: @disable_geoip,
227
+ request_id: @request_id,
228
+ evaluated_at: @evaluated_at,
229
+ flag_definitions_loaded_at: @flag_definitions_loaded_at,
230
+ errors_while_computing: @errors_while_computing,
231
+ quota_limited: @quota_limited,
232
+ minimal_flag_called_events: @minimal_flag_called_events,
233
+ accessed: @accessed.dup
234
+ )
235
+ end
236
+ end
237
+ end