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 +4 -4
- data/CHANGELOG.md +19 -9
- data/README.md +4 -0
- data/lib/toggly/client.rb +1 -0
- data/lib/toggly/config.rb +11 -0
- data/lib/toggly/evaluation_engine.rb +57 -39
- data/lib/toggly/evaluators/targeting.rb +11 -10
- data/lib/toggly/telemetry/runtime.rb +28 -0
- data/lib/toggly/telemetry/system_metrics.rb +243 -0
- data/lib/toggly/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b31072062bc817895a0fb20a5f6a46e4421af0a50ba2273822f5d0a20a16a26
|
|
4
|
+
data.tar.gz: acc89c1befd7029131935a30e4cb134481b636797d82bc0c2e8f96081ca69bf9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
evaluation = evaluator.evaluate(rule, context, feature_key: definition.feature_key)
|
|
126
|
+
next unless rule_passed
|
|
129
127
|
|
|
130
|
-
|
|
128
|
+
matched = true
|
|
129
|
+
matched_rule = rule
|
|
130
|
+
matched_rule_index = index
|
|
131
|
+
break
|
|
132
|
+
end
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
25
|
+
# @return [Boolean] True if matched, false if excluded or rollout does not apply
|
|
26
26
|
def evaluate(rule, context, feature_key: nil)
|
|
27
|
-
|
|
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
|
|
33
|
-
return false if excluded_users.any? { |u| u.to_s ==
|
|
34
|
-
return true if users.include?(
|
|
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|
|
|
41
|
-
return true if groups.any? { |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
|
|
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
|
-
|
|
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
|
data/lib/toggly/version.rb
CHANGED
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:
|
|
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-
|
|
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.
|
|
96
|
+
version: 3.3.0
|
|
96
97
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
98
|
requirements:
|
|
98
99
|
- - ">="
|