toggly 1.4.2 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d58d487720427aea654a419504c20f753656ac862aa355bf25532a97446be208
4
- data.tar.gz: 60ff9681f37a920b85e25e316ffda754fb8bfe018fef1e53ea34ebbc144f437a
3
+ metadata.gz: 6b31072062bc817895a0fb20a5f6a46e4421af0a50ba2273822f5d0a20a16a26
4
+ data.tar.gz: acc89c1befd7029131935a30e4cb134481b636797d82bc0c2e8f96081ca69bf9
5
5
  SHA512:
6
- metadata.gz: 2dc9099207126915eb954e3f6f8f6ce6a9d502da83e88cf9bfcd54c2574f2f4310b03b69627334d33110e5d0ff940ea0e25d33ac34c62b5cd479713b3fc6f415
7
- data.tar.gz: c308b2523eea162fd10680541b103650eb07a92b7e952ad90d60e27b80fcefcc85dd7f1a27c520f03eff64681f1db69c9caf950cb383dbf824455ecce3ee4a1f
6
+ metadata.gz: c6d0aef5c1b1e477a2b706dd9ee29453ce87ac55fa4a39175b3be60c8a7b23c6d34d8e67eacb000aee1d5eb6f0bc4f0c0c7c9943a9d7fecc323677d3e9bccd2d
7
+ data.tar.gz: e6766738d5fe5eaf5b809bbf8347dfa88e8d114bcdad1eacb09cafbab579986a571417788b2472cc2ba6ded7d6abcff32695344b3ff1cff00530a72da0ac3527
data/CHANGELOG.md CHANGED
@@ -5,8 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [1.4.2] - 2026-10-16
8
+ ## [2.0.0] - 2026-10-25
9
+
10
+ ### Changed
11
+
12
+ - Require Ruby 3.3 or newer. Ruby 3.2 reached end of life on April 1, 2026.
9
13
 
14
+ ## [1.4.3] - 2026-10-23
15
+ ### Fixed
16
+ - Sole-filter flags no longer default to enabled when evaluators return `nil`
17
+ (e.g. Targeting with a non-listed user and 0% default rollout). Sequential
18
+ multi-rule chains still fall through on `nil` [OPS-2011].
19
+ - `Targeting` default rollout now matches .NET / toggly-eval / Go: `>= 100%`
20
+ enables without identity; `<= 0%` and partial rollout without identity fail
21
+ closed [OPS-2011].
22
+
23
+ ## [1.4.2] - 2026-10-16
24
+ ### Added
25
+ - Opt-in `system_metrics: true` on `Toggly::Config` samples `CpuUsage`,
26
+ `MemoryWorkingSet`, and `ExceptionCount` every 10s when `enable_metrics` is
27
+ on (Fiddle `getrusage` + Linux `/proc/self/statm`) [OPS-1765].
10
28
  ### Fixed
11
29
  - Usage scopes that raise `Exception` (not only `StandardError`) record an
12
30
  error outcome instead of treating the attempt as success [OPS-1758].
@@ -14,20 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
32
  exception from `$!` and records a failure [OPS-1758].
15
33
  - `Interrupt`, `SystemExit`, and `SignalException` are attempt-only (no
16
34
  error or duration), matching the Python usage-scope contract [OPS-1758].
17
-
18
35
  ## [1.4.1] - 2026-10-15
19
-
20
- ### Fixed
21
36
  - `UsageScopeRunner` uses `ensure` so non-local exits still record success or
22
37
  failure duration after the attempt [OPS-1758].
23
-
24
38
  ## [1.4.0] - 2026-10-11
25
-
26
- ### Added
27
39
  - `Client#use` usage scope (attempt + duration + errors) and `VariantStats`
28
40
  wire fields `errorCount`, `durationSumMs`, `errorDurationSumMs` [OPS-1758].
29
-
30
- ### Fixed
31
41
  - `Client#use` and `UsageScopeRunner.run` return the block's value [OPS-1758].
32
42
 
33
43
  ## [1.3.0] - 2026-10-04
data/README.md CHANGED
@@ -87,6 +87,10 @@ gRPC metadata includes `ua` = `toggly-ruby/{VERSION}` against
87
87
  `metrics_base_url` (default `https://app.toggly.io/`). Without the optional
88
88
  `grpc` gem, evaluation still works; send is a no-op with a warning.
89
89
 
90
+ Set `system_metrics: true` (default `false`) to sample `CpuUsage`,
91
+ `MemoryWorkingSet`, and `ExceptionCount` every 10s when `enable_metrics` is on
92
+ (stdlib Fiddle `getrusage` + Linux `/proc/self/statm`).
93
+
90
94
  ## Documentation
91
95
 
92
96
  See the [main README](../README.md) for full documentation.
data/lib/toggly/client.rb CHANGED
@@ -536,6 +536,7 @@ module Toggly
536
536
  metrics_client: @config.metrics_client,
537
537
  usage_client_provided: usage_provided,
538
538
  metrics_client_provided: metrics_provided,
539
+ system_metrics: @config.system_metrics,
539
540
  logger: @config.logger
540
541
  )
541
542
  @telemetry.start
data/lib/toggly/config.rb CHANGED
@@ -75,6 +75,9 @@ module Toggly
75
75
  # @return [Boolean] Enable business metrics (Metrics.SendMetrics)
76
76
  attr_reader :enable_metrics
77
77
 
78
+ # @return [Boolean] Opt-in process metrics every 10s (requires enable_metrics)
79
+ attr_reader :system_metrics
80
+
78
81
  # @return [String] Base URL for usage/metrics gRPC (default app.toggly.io)
79
82
  attr_accessor :metrics_base_url
80
83
 
@@ -120,8 +123,10 @@ module Toggly
120
123
 
121
124
  @usage_tracking_explicit = options.key?(:enable_usage_tracking)
122
125
  @metrics_explicit = options.key?(:enable_metrics)
126
+ @system_metrics_explicit = options.key?(:system_metrics)
123
127
  @enable_usage_tracking = options[:enable_usage_tracking] if @usage_tracking_explicit
124
128
  @enable_metrics = options[:enable_metrics] if @metrics_explicit
129
+ @system_metrics = options.fetch(:system_metrics, false)
125
130
  apply_telemetry_defaults!
126
131
  @metrics_base_url = normalize_url(options[:metrics_base_url] || DEFAULT_METRICS_BASE_URL)
127
132
  @usage_flush_interval = options.fetch(:usage_flush_interval, DEFAULT_TELEMETRY_FLUSH_SECONDS)
@@ -164,6 +169,11 @@ module Toggly
164
169
  @enable_metrics = value
165
170
  end
166
171
 
172
+ def system_metrics=(value)
173
+ @system_metrics_explicit = true
174
+ @system_metrics = value ? true : false
175
+ end
176
+
167
177
  # Apply usage/metrics defaults from the current +app_key+ and
168
178
  # +TOGGLY_DISABLE_TELEMETRY+. Explicit +enable_usage_tracking+ /
169
179
  # +enable_metrics+ assignments still win. Call after a configure block
@@ -214,6 +224,7 @@ module Toggly
214
224
  use_signed_definitions: @use_signed_definitions,
215
225
  enable_usage_tracking: @enable_usage_tracking,
216
226
  enable_metrics: @enable_metrics,
227
+ system_metrics: @system_metrics,
217
228
  metrics_base_url: @metrics_base_url,
218
229
  usage_flush_interval: @usage_flush_interval,
219
230
  metrics_flush_interval: @metrics_flush_interval
@@ -82,7 +82,8 @@ module Toggly
82
82
  return evaluate_filter_group(user_rules, definition.requirement_type, context, definition.feature_key, result)
83
83
  end
84
84
 
85
- sequential_rules(user_rules.empty? ? definition.rules : user_rules, definition, context, result)
85
+ rules = user_rules.empty? ? definition.rules : user_rules
86
+ evaluate_filter_group(rules, definition.requirement_type, context, definition.feature_key, result)
86
87
  end
87
88
 
88
89
  def evaluate_entity_group(definition, rules, context)
@@ -96,54 +97,71 @@ module Toggly
96
97
 
97
98
  def evaluate_filter_group(rules, requirement_type, context, feature_key, result)
98
99
  req = (requirement_type || "Any").to_s
99
- evaluations = rules.map do |rule|
100
- rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "AlwaysOn"
101
- evaluator = @registry.get(rule_type)
102
- next false unless evaluator
103
-
104
- begin
105
- value = evaluator.evaluate(rule, context, feature_key: feature_key)
106
- value == true
107
- rescue StandardError
108
- false
109
- end
110
- end
111
- passed = req.casecmp?("All") ? evaluations.all? : evaluations.any?
112
- result&.reason = passed ? "rule_matched" : "no_match" if result
113
- passed
114
- end
100
+ all_mode = req.casecmp?("All")
101
+ matched = false
102
+ matched_rule = nil
103
+ matched_rule_index = nil
115
104
 
116
- def sequential_rules(rules, definition, context, result)
117
105
  rules.each_with_index do |rule, index|
118
- rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "AlwaysOn"
119
- evaluator = @registry.get(rule_type)
106
+ rule_passed, unknown = evaluate_filter_rule(rule, index, context, feature_key)
107
+ if unknown
108
+ if all_mode
109
+ result&.reason = "unknown_filter"
110
+ return false
111
+ end
112
+ next
113
+ end
120
114
 
121
- unless evaluator
122
- log_warn("Unknown evaluator type: #{rule_type} for feature #{definition.feature_key}")
123
- result&.reason = "unknown_filter"
124
- return false
115
+ if all_mode
116
+ unless rule_passed
117
+ result&.reason = "no_match" if result
118
+ return false
119
+ end
120
+ matched = true
121
+ matched_rule = rule
122
+ matched_rule_index = index
123
+ next
125
124
  end
126
125
 
127
- begin
128
- evaluation = evaluator.evaluate(rule, context, feature_key: definition.feature_key)
126
+ next unless rule_passed
129
127
 
130
- next if evaluation.nil?
128
+ matched = true
129
+ matched_rule = rule
130
+ matched_rule_index = index
131
+ break
132
+ end
131
133
 
132
- if result
133
- result.matched_rule = rule
134
- result.matched_rule_index = index
135
- result.reason = "rule_matched"
136
- end
134
+ record_filter_group_result(result, matched, matched_rule, matched_rule_index)
135
+ matched
136
+ end
137
137
 
138
- return evaluation
139
- rescue StandardError => e
140
- log_error("Error evaluating rule #{index} for #{definition.feature_key}: #{e.message}")
141
- next
142
- end
138
+ def evaluate_filter_rule(rule, index, context, feature_key)
139
+ rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "AlwaysOn"
140
+ evaluator = @registry.get(rule_type)
141
+ unless evaluator
142
+ log_warn("Unknown evaluator type: #{rule_type} for feature #{feature_key}")
143
+ return [false, true]
143
144
  end
144
145
 
145
- result&.reason = "default_enabled"
146
- true
146
+ begin
147
+ value = evaluator.evaluate(rule, context, feature_key: feature_key)
148
+ [value == true, false]
149
+ rescue StandardError => e
150
+ log_error("Error evaluating filter #{index} for #{feature_key}: #{e.message}")
151
+ [false, false]
152
+ end
153
+ end
154
+
155
+ def record_filter_group_result(result, matched, matched_rule, matched_rule_index)
156
+ return unless result
157
+
158
+ if matched
159
+ result.matched_rule = matched_rule
160
+ result.matched_rule_index = matched_rule_index
161
+ result.reason = "rule_matched"
162
+ else
163
+ result.reason = "no_match"
164
+ end
147
165
  end
148
166
 
149
167
  def log_warn(message)
@@ -22,23 +22,23 @@ module Toggly
22
22
  # @param rule [Hash] Rule with users/groups or Audience.* params
23
23
  # @param context [Context] Evaluation context
24
24
  # @param feature_key [String] The feature key
25
- # @return [Boolean, nil] True if matched, false if excluded, nil to continue
25
+ # @return [Boolean] True if matched, false if excluded or rollout does not apply
26
26
  def evaluate(rule, context, feature_key: nil)
27
- return nil unless context
27
+ ctx = context || Context.anonymous
28
28
 
29
29
  users = collect_users(rule)
30
30
  excluded_users = Array(rule_value(rule, "excludedUsers") || rule_value(rule, "excluded_users"))
31
31
 
32
- if context.identity?
33
- return false if excluded_users.any? { |u| u.to_s == context.identity }
34
- return true if users.include?(context.identity)
32
+ if ctx.identity?
33
+ return false if excluded_users.any? { |u| u.to_s == ctx.identity }
34
+ return true if users.include?(ctx.identity)
35
35
  end
36
36
 
37
37
  groups = collect_groups(rule)
38
38
  excluded_groups = Array(rule_value(rule, "excludedGroups") || rule_value(rule, "excluded_groups"))
39
39
 
40
- return false if excluded_groups.any? { |g| context.in_group?(g) }
41
- return true if groups.any? { |g| context.in_group?(g) }
40
+ return false if excluded_groups.any? { |g| ctx.in_group?(g) }
41
+ return true if groups.any? { |g| ctx.in_group?(g) }
42
42
 
43
43
  default_percentage = SegmentHelpers.as_float(
44
44
  rule,
@@ -48,10 +48,11 @@ module Toggly
48
48
  "default_percentage",
49
49
  "Percentage"
50
50
  )
51
- return StickyHash.compute_percentile(context.identity, feature_key.to_s) < default_percentage if default_percentage&.positive? && context.identity?
51
+ return false if default_percentage.nil? || default_percentage <= 0
52
+ return true if default_percentage >= 100
53
+ return false unless ctx.identity?
52
54
 
53
- # No match — continue evaluation for legacy sequential rules
54
- nil
55
+ StickyHash.compute_percentile(ctx.identity, feature_key.to_s) < default_percentage
55
56
  end
56
57
 
57
58
  private
@@ -25,6 +25,9 @@ module Toggly
25
25
  metrics_client: nil,
26
26
  usage_client_provided: false,
27
27
  metrics_client_provided: false,
28
+ system_metrics: false,
29
+ system_metrics_sampler: nil,
30
+ system_metrics_interval: nil,
28
31
  logger: nil
29
32
  )
30
33
  # rubocop:enable Metrics/ParameterLists
@@ -62,6 +65,10 @@ module Toggly
62
65
  @process_start_time = Time.now.utc
63
66
  @atexit_registered = false
64
67
  @warned_missing_grpc = false
68
+ @system_metrics = system_metrics ? true : false
69
+ @system_metrics_sampler = system_metrics_sampler
70
+ @system_metrics_interval = system_metrics_interval
71
+ @system_metrics_collector = nil
65
72
  end
66
73
 
67
74
  def usage_enabled?
@@ -102,6 +109,8 @@ module Toggly
102
109
  schedule_metrics_flush if @metrics_flush_interval.positive?
103
110
  end
104
111
 
112
+ start_system_metrics if @enable_metrics && @system_metrics
113
+
105
114
  attach_exit_handlers
106
115
  end
107
116
 
@@ -173,6 +182,8 @@ module Toggly
173
182
  # Wait for flush loops (including in-flight sends) — do not Thread#kill.
174
183
  timers.each { |thread| join_timer(thread) }
175
184
 
185
+ stop_system_metrics
186
+
176
187
  begin
177
188
  flush_all
178
189
  ensure
@@ -270,6 +281,23 @@ module Toggly
270
281
  def log_error(message)
271
282
  @logger&.error("[Toggly] #{message}")
272
283
  end
284
+
285
+ def start_system_metrics
286
+ require_relative "system_metrics"
287
+ interval = @system_metrics_interval || SystemMetrics::INTERVAL_SECONDS
288
+ @system_metrics_collector = SystemMetrics::Collector.new(
289
+ observe: method(:observe),
290
+ sampler: @system_metrics_sampler,
291
+ interval: interval
292
+ )
293
+ @system_metrics_collector.start
294
+ end
295
+
296
+ def stop_system_metrics
297
+ collector = @system_metrics_collector
298
+ @system_metrics_collector = nil
299
+ collector&.stop
300
+ end
273
301
  end
274
302
  end
275
303
  end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ begin
6
+ require "fiddle"
7
+ rescue LoadError
8
+ # Optional: getrusage sampling is skipped when fiddle is unavailable.
9
+ end
10
+
11
+ module Toggly
12
+ module Telemetry
13
+ # Opt-in process metrics (CpuUsage, MemoryWorkingSet, ExceptionCount).
14
+ module SystemMetrics
15
+ INTERVAL_SECONDS = 10.0
16
+
17
+ # FFI helpers for getrusage-based CPU and RSS samples.
18
+ module RusageFFI
19
+ module_function
20
+
21
+ def read_rusage
22
+ return nil unless defined?(@getrusage) && @getrusage
23
+
24
+ # RUBY_FREE auto-releases when the pointer is GC'd / goes out of scope.
25
+ freer = defined?(Fiddle::RUBY_FREE) ? Fiddle::RUBY_FREE : nil
26
+ ptr = if freer
27
+ Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG * 18, freer)
28
+ else
29
+ Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG * 18)
30
+ end
31
+ begin
32
+ return nil if @getrusage.call(RUSAGE_SELF, ptr) != 0
33
+
34
+ utime_sec = ptr[0, Fiddle::SIZEOF_LONG].unpack1("l!")
35
+ utime_usec = ptr[Fiddle::SIZEOF_LONG, Fiddle::SIZEOF_LONG].unpack1("l!")
36
+ stime_sec = ptr[2 * Fiddle::SIZEOF_LONG, Fiddle::SIZEOF_LONG].unpack1("l!")
37
+ stime_usec = ptr[3 * Fiddle::SIZEOF_LONG, Fiddle::SIZEOF_LONG].unpack1("l!")
38
+ maxrss = ptr[4 * Fiddle::SIZEOF_LONG, Fiddle::SIZEOF_LONG].unpack1("l!")
39
+ {
40
+ user_us: (utime_sec * 1_000_000) + utime_usec,
41
+ sys_us: (stime_sec * 1_000_000) + stime_usec,
42
+ maxrss: maxrss
43
+ }
44
+ ensure
45
+ if freer.nil? && ptr
46
+ if ptr.respond_to?(:call_free)
47
+ ptr.call_free
48
+ elsif ptr.respond_to?(:free) && ptr.free.respond_to?(:call)
49
+ ptr.free.call(ptr)
50
+ end
51
+ end
52
+ end
53
+ rescue StandardError
54
+ nil
55
+ end
56
+
57
+ RUSAGE_SELF = 0
58
+ begin
59
+ libc = Fiddle.dlopen(nil)
60
+ @getrusage = Fiddle::Function.new(
61
+ libc["getrusage"],
62
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
63
+ Fiddle::TYPE_INT
64
+ )
65
+ rescue StandardError
66
+ @getrusage = nil
67
+ end
68
+ end
69
+
70
+ # Periodic sampler that posts gauges through the provided observe callback.
71
+ class Collector
72
+ def initialize(observe:, sampler: nil, interval: INTERVAL_SECONDS)
73
+ @observe = observe
74
+ @sampler = sampler
75
+ @interval = interval.to_f
76
+ @mutex = Mutex.new
77
+ @exception_count = 0
78
+ @last_cpu = nil
79
+ @thread = nil
80
+ @closed = false
81
+ @trace = nil
82
+ @ended_threads = []
83
+ install_exception_handler
84
+ end
85
+
86
+ def start
87
+ return if @thread&.alive?
88
+
89
+ @closed = false
90
+ @thread = Thread.new { run_loop }
91
+ @thread.abort_on_exception = false
92
+ end
93
+
94
+ def stop
95
+ @mutex.synchronize { @closed = true }
96
+ @thread&.wakeup if @thread&.alive?
97
+ @thread&.join(2.0) if @thread&.alive?
98
+ @thread = nil
99
+ disable_exception_handler
100
+ end
101
+
102
+ def tick
103
+ return if @closed
104
+
105
+ drain_ended_threads
106
+ samples = @sampler ? @sampler.call : default_sample
107
+ samples.each do |metric, value|
108
+ next unless value.is_a?(Numeric) && value.finite? && value >= 0
109
+
110
+ @observe.call(metric, value.to_f)
111
+ end
112
+ exceptions = @mutex.synchronize do
113
+ count = @exception_count
114
+ @exception_count = 0
115
+ count
116
+ end
117
+ @observe.call("ExceptionCount", exceptions.to_f) if exceptions.positive?
118
+ rescue StandardError
119
+ # Never disrupt the host process.
120
+ end
121
+
122
+ private
123
+
124
+ def run_loop
125
+ loop do
126
+ sleep(@interval)
127
+ break if @mutex.synchronize { @closed }
128
+
129
+ tick
130
+ end
131
+ end
132
+
133
+ def default_sample
134
+ out = []
135
+ cpu = sample_cpu
136
+ out << ["CpuUsage", cpu] if cpu
137
+ mem = sample_memory_working_set
138
+ out << ["MemoryWorkingSet", mem] if mem
139
+ out
140
+ end
141
+
142
+ def sample_cpu
143
+ ru = RusageFFI.read_rusage
144
+ return nil unless ru
145
+
146
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ user_us = ru[:user_us]
148
+ sys_us = ru[:sys_us]
149
+ prev = @mutex.synchronize do
150
+ p = @last_cpu
151
+ @last_cpu = { user_us: user_us, sys_us: sys_us, at: now }
152
+ p
153
+ end
154
+ return nil unless prev
155
+
156
+ wall_us = [(now - prev[:at]) * 1_000_000, 1].max
157
+ cpu_us = (user_us - prev[:user_us]) + (sys_us - prev[:sys_us])
158
+ return nil if cpu_us.negative?
159
+
160
+ n_cpu = [Etc.nprocessors, 1].max
161
+ pct = (cpu_us.to_f / wall_us / n_cpu) * 100.0
162
+ return nil unless pct.finite? && pct >= 0
163
+
164
+ [pct, 100.0].min
165
+ end
166
+
167
+ def sample_memory_working_set
168
+ if (rss = read_linux_statm_rss)
169
+ return rss.to_f
170
+ end
171
+
172
+ ru = RusageFFI.read_rusage
173
+ return nil unless ru && ru[:maxrss].to_i.positive?
174
+
175
+ rss = ru[:maxrss]
176
+ rss *= 1024 if RUBY_PLATFORM.include?("linux")
177
+ rss.to_f
178
+ end
179
+
180
+ def read_linux_statm_rss
181
+ return nil unless RUBY_PLATFORM.include?("linux")
182
+
183
+ line = File.read("/proc/self/statm").split
184
+ pages = line[1].to_i
185
+ page_size = begin
186
+ if Process.respond_to?(:getpagesize)
187
+ Process.getpagesize
188
+ elsif defined?(Etc::SC_PAGESIZE)
189
+ Etc.sysconf(Etc::SC_PAGESIZE)
190
+ elsif defined?(Etc::SC_PAGE_SIZE)
191
+ Etc.sysconf(Etc::SC_PAGE_SIZE)
192
+ else
193
+ Integer(ENV.fetch("PAGESIZE", 4096))
194
+ end
195
+ rescue StandardError
196
+ 4096
197
+ end
198
+ pages * page_size
199
+ rescue StandardError
200
+ nil
201
+ end
202
+
203
+ def install_exception_handler
204
+ return if @trace
205
+
206
+ # Do not call Thread#join/#value inside TracePoint — that raises
207
+ # ThreadError for normal exits. Queue ended threads and inspect on tick.
208
+ @trace = TracePoint.new(:thread_end) do |tp|
209
+ thread = tp.self
210
+ next if thread == Thread.main
211
+ next if thread.status == "aborting"
212
+
213
+ @mutex.synchronize { @ended_threads << thread }
214
+ end
215
+ @trace.enable
216
+ end
217
+
218
+ def drain_ended_threads
219
+ ended = @mutex.synchronize do
220
+ list = @ended_threads
221
+ @ended_threads = []
222
+ list
223
+ end
224
+ ended.each do |thread|
225
+ thread.value
226
+ rescue StandardError, ScriptError
227
+ @mutex.synchronize { @exception_count += 1 }
228
+ rescue Exception # rubocop:disable Lint/RescueException
229
+ nil
230
+ end
231
+ end
232
+
233
+ def disable_exception_handler
234
+ return unless @trace
235
+
236
+ @trace.disable
237
+ @trace = nil
238
+ drain_ended_threads
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "1.4.2"
4
+ VERSION = "2.0.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toggly
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.2
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: High-performance Ruby SDK for Toggly feature flag management. Works with
14
14
  or without Toggly.io.
@@ -66,6 +66,7 @@ files:
66
66
  - lib/toggly/telemetry/pb/usage_services_pb.rb
67
67
  - lib/toggly/telemetry/runtime.rb
68
68
  - lib/toggly/telemetry/runtime_flush.rb
69
+ - lib/toggly/telemetry/system_metrics.rb
69
70
  - lib/toggly/telemetry/usage_batcher.rb
70
71
  - lib/toggly/telemetry/usage_scope.rb
71
72
  - lib/toggly/user_agent_parser.rb
@@ -92,7 +93,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
92
93
  requirements:
93
94
  - - ">="
94
95
  - !ruby/object:Gem::Version
95
- version: 3.2.0
96
+ version: 3.3.0
96
97
  required_rubygems_version: !ruby/object:Gem::Requirement
97
98
  requirements:
98
99
  - - ">="