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,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
|