toggly 1.3.0 → 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: 8e7e0a87a56b437ad26a416ec236125cdffab5f1ed3ac0a65260da717c58c04d
4
- data.tar.gz: 4e3a36875f88ee85dbe81ae51cb72226b97cd3951d8034ddf600d2f8a5ce8da6
3
+ metadata.gz: 6b31072062bc817895a0fb20a5f6a46e4421af0a50ba2273822f5d0a20a16a26
4
+ data.tar.gz: acc89c1befd7029131935a30e4cb134481b636797d82bc0c2e8f96081ca69bf9
5
5
  SHA512:
6
- metadata.gz: 0e8ddc1874675e6536462478a8245f964470951cede5dae334c13383cd34af96627a6be8a1f8778eb0b2941dc74433714113b85167f88a472ff88d7e1b763094
7
- data.tar.gz: d743ba544845147aff474191418ab201a3945c3e8edad5ccb78d34364d175a8a8aa1b0cf38ac5c3122f95517670b75416261821b5dd75d214fe0d3483b789a72
6
+ metadata.gz: c6d0aef5c1b1e477a2b706dd9ee29453ce87ac55fa4a39175b3be60c8a7b23c6d34d8e67eacb000aee1d5eb6f0bc4f0c0c7c9943a9d7fecc323677d3e9bccd2d
7
+ data.tar.gz: e6766738d5fe5eaf5b809bbf8347dfa88e8d114bcdad1eacb09cafbab579986a571417788b2472cc2ba6ded7d6abcff32695344b3ff1cff00530a72da0ac3527
data/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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
+ ## [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.
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].
28
+ ### Fixed
29
+ - Usage scopes that raise `Exception` (not only `StandardError`) record an
30
+ error outcome instead of treating the attempt as success [OPS-1758].
31
+ - A clean usage scope nested in an outer `rescue` no longer inherits that
32
+ exception from `$!` and records a failure [OPS-1758].
33
+ - `Interrupt`, `SystemExit`, and `SignalException` are attempt-only (no
34
+ error or duration), matching the Python usage-scope contract [OPS-1758].
35
+ ## [1.4.1] - 2026-10-15
36
+ - `UsageScopeRunner` uses `ensure` so non-local exits still record success or
37
+ failure duration after the attempt [OPS-1758].
38
+ ## [1.4.0] - 2026-10-11
39
+ - `Client#use` usage scope (attempt + duration + errors) and `VariantStats`
40
+ wire fields `errorCount`, `durationSumMs`, `errorDurationSumMs` [OPS-1758].
41
+ - `Client#use` and `UsageScopeRunner.run` return the block's value [OPS-1758].
42
+
8
43
  ## [1.3.0] - 2026-10-04
9
44
 
10
45
  ### Added
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
@@ -273,6 +273,24 @@ module Toggly
273
273
  @telemetry.record_usage(feature_key.to_s, identity, variant: variant)
274
274
  end
275
275
 
276
+ # Run a block inside a usage scope (attempt + duration + errors). Does not evaluate the flag.
277
+ #
278
+ # @yieldparam scope [Telemetry::FeatureUsageScope]
279
+ def use(feature_key, identity: nil, variant: "enabled", &)
280
+ key = feature_key.to_s
281
+ resolved_identity = identity.nil? ? variant_user_id(nil) : identity
282
+ handlers = [
283
+ -> { @telemetry&.record_usage(key, resolved_identity, variant: variant) },
284
+ ->(ms) { @telemetry&.record_usage_scope_success(key, ms, variant: variant) },
285
+ ->(ms) { @telemetry&.record_usage_scope_failure(key, ms, variant: variant) }
286
+ ]
287
+ if @telemetry.nil? || !@telemetry.usage_enabled?
288
+ scope = Telemetry::FeatureUsageScope.new
289
+ return yield scope
290
+ end
291
+ Telemetry::UsageScopeRunner.run(handlers, &)
292
+ end
293
+
276
294
  # Record a feature viewed/rendered event
277
295
  #
278
296
  # @param feature_key [String, Symbol]
@@ -518,6 +536,7 @@ module Toggly
518
536
  metrics_client: @config.metrics_client,
519
537
  usage_client_provided: usage_provided,
520
538
  metrics_client_provided: metrics_provided,
539
+ system_metrics: @config.system_metrics,
521
540
  logger: @config.logger
522
541
  )
523
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
@@ -231,7 +231,10 @@ module Toggly
231
231
  checkCount: vs[:checkCount].to_i,
232
232
  requestCount: vs[:requestCount].to_i,
233
233
  usedCount: vs[:usedCount].to_i,
234
- viewedCount: vs[:viewedCount].to_i
234
+ viewedCount: vs[:viewedCount].to_i,
235
+ errorCount: vs[:errorCount].to_i,
236
+ durationSumMs: vs[:durationSumMs].to_i,
237
+ errorDurationSumMs: vs[:errorDurationSumMs].to_i
235
238
  )
236
239
  end
237
240
  sm
@@ -8,7 +8,7 @@
8
8
  require "google/protobuf"
9
9
  require "google/protobuf/timestamp_pb"
10
10
 
11
- descriptor_data = "\n\x0busage.proto\x12\x05Usage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcf\x03\n\x0b\x46\x65\x61tureStat\x12\x0e\n\x06\x61ppKey\x18\x01 \x01(\t\x12\x13\n\x0b\x65nvironment\x18\x02 \x01(\t\x12(\n\x04time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12!\n\x05stats\x18\x04 \x03(\x0b\x32\x12.Usage.StatMessage\x12\x18\n\x10totalUniqueUsers\x18\x05 \x01(\x05\x12\x19\n\x0cinstanceName\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x39\n\x10processStartTime\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x01\x88\x01\x01\x12\x17\n\nappVersion\x18\x08 \x01(\tH\x02\x88\x01\x01\x12\x18\n\x10uniqueUserHashes\x18\t \x03(\x05\x12 \n\x13\x64\x65\x66initionCacheHits\x18\n \x01(\x05H\x03\x88\x01\x01\x12\"\n\x15\x64\x65\x66initionCacheMisses\x18\x0b \x01(\x05H\x04\x88\x01\x01\x42\x0f\n\r_instanceNameB\x13\n\x11_processStartTimeB\r\n\x0b_appVersionB\x16\n\x14_definitionCacheHitsB\x18\n\x16_definitionCacheMisses\"\xf2\x03\n\x0bStatMessage\x12\x0f\n\x07\x66\x65\x61ture\x18\x01 \x01(\t\x12\x18\n\x0c\x65nabledCount\x18\x02 \x01(\x05\x42\x02\x18\x01\x12\x19\n\rdisabledCount\x18\x03 \x01(\x05\x42\x02\x18\x01\x12+\n#uniqueContextIdentifierEnabledCount\x18\x04 \x01(\x05\x12,\n$uniqueContextIdentifierDisabledCount\x18\x05 \x01(\x05\x12%\n\x19uniqueRequestEnabledCount\x18\x06 \x01(\x05\x42\x02\x18\x01\x12&\n\x1auniqueRequestDisabledCount\x18\x07 \x01(\x05\x42\x02\x18\x01\x12\x15\n\tusedCount\x18\x08 \x01(\x05\x42\x02\x18\x01\x12\x1c\n\x14uniqueUsersUsedCount\x18\t \x01(\x05\x12\x18\n\x10uniqueUserHashes\x18\n \x03(\x05\x12:\n\x0cvariantStats\x18\x0b \x03(\x0b\x32$.Usage.StatMessage.VariantStatsEntry\x12\x1e\n\x16uniqueViewedUserHashes\x18\x0c \x03(\x05\x1aH\n\x11VariantStatsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\"\n\x05value\x18\x02 \x01(\x0b\x32\x13.Usage.VariantStats:\x02\x38\x01\"`\n\x0cVariantStats\x12\x12\n\ncheckCount\x18\x01 \x01(\x05\x12\x14\n\x0crequestCount\x18\x02 \x01(\x05\x12\x11\n\tusedCount\x18\x03 \x01(\x05\x12\x13\n\x0bviewedCount\x18\x04 \x01(\x05\"\"\n\nStatResult\x12\x14\n\x0c\x66\x65\x61tureCount\x18\x01 \x01(\x05\x32;\n\x05Usage\x12\x32\n\tSendStats\x12\x12.Usage.FeatureStat\x1a\x11.Usage.StatResultBKZIgithub.com/ops-ai/Toggly.FeatureManagement/toggly-go/toggly/usage/usagepbb\x06proto3"
11
+ descriptor_data = "\n\x0busage.proto\x12\x05Usage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcf\x03\n\x0b\x46\x65\x61tureStat\x12\x0e\n\x06\x61ppKey\x18\x01 \x01(\t\x12\x13\n\x0b\x65nvironment\x18\x02 \x01(\t\x12(\n\x04time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12!\n\x05stats\x18\x04 \x03(\x0b\x32\x12.Usage.StatMessage\x12\x18\n\x10totalUniqueUsers\x18\x05 \x01(\x05\x12\x19\n\x0cinstanceName\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x39\n\x10processStartTime\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x01\x88\x01\x01\x12\x17\n\nappVersion\x18\x08 \x01(\tH\x02\x88\x01\x01\x12\x18\n\x10uniqueUserHashes\x18\t \x03(\x05\x12 \n\x13\x64\x65\x66initionCacheHits\x18\n \x01(\x05H\x03\x88\x01\x01\x12\"\n\x15\x64\x65\x66initionCacheMisses\x18\x0b \x01(\x05H\x04\x88\x01\x01\x42\x0f\n\r_instanceNameB\x13\n\x11_processStartTimeB\r\n\x0b_appVersionB\x16\n\x14_definitionCacheHitsB\x18\n\x16_definitionCacheMisses\"\xf2\x03\n\x0bStatMessage\x12\x0f\n\x07\x66\x65\x61ture\x18\x01 \x01(\t\x12\x18\n\x0c\x65nabledCount\x18\x02 \x01(\x05\x42\x02\x18\x01\x12\x19\n\rdisabledCount\x18\x03 \x01(\x05\x42\x02\x18\x01\x12+\n#uniqueContextIdentifierEnabledCount\x18\x04 \x01(\x05\x12,\n$uniqueContextIdentifierDisabledCount\x18\x05 \x01(\x05\x12%\n\x19uniqueRequestEnabledCount\x18\x06 \x01(\x05\x42\x02\x18\x01\x12&\n\x1auniqueRequestDisabledCount\x18\x07 \x01(\x05\x42\x02\x18\x01\x12\x15\n\tusedCount\x18\x08 \x01(\x05\x42\x02\x18\x01\x12\x1c\n\x14uniqueUsersUsedCount\x18\t \x01(\x05\x12\x18\n\x10uniqueUserHashes\x18\n \x03(\x05\x12:\n\x0cvariantStats\x18\x0b \x03(\x0b\x32$.Usage.StatMessage.VariantStatsEntry\x12\x1e\n\x16uniqueViewedUserHashes\x18\x0c \x03(\x05\x1aH\n\x11VariantStatsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\"\n\x05value\x18\x02 \x01(\x0b\x32\x13.Usage.VariantStats:\x02\x38\x01\"\xa7\x01\n\x0cVariantStats\x12\x12\n\ncheckCount\x18\x01 \x01(\x05\x12\x14\n\x0crequestCount\x18\x02 \x01(\x05\x12\x11\n\tusedCount\x18\x03 \x01(\x05\x12\x13\n\x0bviewedCount\x18\x04 \x01(\x05\x12\x12\n\nerrorCount\x18\x05 \x01(\x05\x12\x15\n\rdurationSumMs\x18\x06 \x01(\x03\x12\x1a\n\x12\x65rrorDurationSumMs\x18\x07 \x01(\x03\"\"\n\nStatResult\x12\x14\n\x0c\x66\x65\x61tureCount\x18\x01 \x01(\x05\x32;\n\x05Usage\x12\x32\n\tSendStats\x12\x12.Usage.FeatureStat\x1a\x11.Usage.StatResultBKZIgithub.com/ops-ai/Toggly.FeatureManagement/toggly-go/toggly/usage/usagepbb\x06proto3"
12
12
 
13
13
  pool = ::Google::Protobuf::DescriptorPool.generated_pool
14
14
  pool.add_serialized_file(descriptor_data)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
- # Generated by the protocol buffer compiler. DO NOT EDIT!
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # Source: usage.proto for package 'Usage'
4
4
 
5
5
  require "grpc"
@@ -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
 
@@ -120,6 +129,19 @@ module Toggly
120
129
  batcher&.record_view(feature, identity, variant: variant)
121
130
  end
122
131
 
132
+ def record_usage_scope_success(feature, duration_ms, variant: "enabled")
133
+ batcher = @mutex.synchronize { @usage_batcher }
134
+ batcher&.record_usage_duration(feature, duration_ms, variant: variant, failed: false)
135
+ end
136
+
137
+ def record_usage_scope_failure(feature, duration_ms, variant: "enabled")
138
+ batcher = @mutex.synchronize { @usage_batcher }
139
+ return unless batcher
140
+
141
+ batcher.record_usage_error(feature, variant: variant)
142
+ batcher.record_usage_duration(feature, duration_ms, variant: variant, failed: true)
143
+ end
144
+
123
145
  def record_definition_cache_hit
124
146
  batcher = @mutex.synchronize { @usage_batcher }
125
147
  batcher&.record_definition_cache_hit
@@ -160,6 +182,8 @@ module Toggly
160
182
  # Wait for flush loops (including in-flight sends) — do not Thread#kill.
161
183
  timers.each { |thread| join_timer(thread) }
162
184
 
185
+ stop_system_metrics
186
+
163
187
  begin
164
188
  flush_all
165
189
  ensure
@@ -257,6 +281,23 @@ module Toggly
257
281
  def log_error(message)
258
282
  @logger&.error("[Toggly] #{message}")
259
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
260
301
  end
261
302
  end
262
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
@@ -4,8 +4,25 @@ module Toggly
4
4
  module Telemetry
5
5
  # In-memory feature usage aggregator (Usage.SendStats payload shape).
6
6
  class UsageBatcher
7
- VariantStatsAgg = Struct.new(:check_count, :request_count, :used_count, :viewed_count, keyword_init: true) do
8
- def initialize(check_count: 0, request_count: 0, used_count: 0, viewed_count: 0)
7
+ VariantStatsAgg = Struct.new(
8
+ :check_count,
9
+ :request_count,
10
+ :used_count,
11
+ :viewed_count,
12
+ :error_count,
13
+ :duration_sum_ms,
14
+ :error_duration_sum_ms,
15
+ keyword_init: true
16
+ ) do
17
+ def initialize(
18
+ check_count: 0,
19
+ request_count: 0,
20
+ used_count: 0,
21
+ viewed_count: 0,
22
+ error_count: 0,
23
+ duration_sum_ms: 0,
24
+ error_duration_sum_ms: 0
25
+ )
9
26
  super
10
27
  end
11
28
 
@@ -14,7 +31,10 @@ module Toggly
14
31
  checkCount: check_count,
15
32
  requestCount: request_count,
16
33
  usedCount: used_count,
17
- viewedCount: viewed_count
34
+ viewedCount: viewed_count,
35
+ errorCount: error_count,
36
+ durationSumMs: duration_sum_ms,
37
+ errorDurationSumMs: error_duration_sum_ms
18
38
  }
19
39
  end
20
40
 
@@ -23,7 +43,10 @@ module Toggly
23
43
  check_count: check_count,
24
44
  request_count: request_count,
25
45
  used_count: used_count,
26
- viewed_count: viewed_count
46
+ viewed_count: viewed_count,
47
+ error_count: error_count,
48
+ duration_sum_ms: duration_sum_ms,
49
+ error_duration_sum_ms: error_duration_sum_ms
27
50
  )
28
51
  end
29
52
  end
@@ -138,6 +161,27 @@ module Toggly
138
161
  end
139
162
  end
140
163
 
164
+ def record_usage_error(feature, variant: "enabled", count: 1)
165
+ return if count <= 0
166
+
167
+ @mutex.synchronize do
168
+ get_variant(get_feature(feature), variant.to_s).error_count += count
169
+ end
170
+ end
171
+
172
+ def record_usage_duration(feature, duration_ms, variant: "enabled", failed: false)
173
+ return if duration_ms <= 0
174
+
175
+ @mutex.synchronize do
176
+ stats = get_variant(get_feature(feature), variant.to_s)
177
+ if failed
178
+ stats.error_duration_sum_ms += duration_ms
179
+ else
180
+ stats.duration_sum_ms += duration_ms
181
+ end
182
+ end
183
+ end
184
+
141
185
  def empty?
142
186
  @mutex.synchronize { empty_unlocked? }
143
187
  end
@@ -180,6 +224,9 @@ module Toggly
180
224
  stats.request_count += snap_stats.request_count
181
225
  stats.used_count += snap_stats.used_count
182
226
  stats.viewed_count += snap_stats.viewed_count
227
+ stats.error_count += snap_stats.error_count
228
+ stats.duration_sum_ms += snap_stats.duration_sum_ms
229
+ stats.error_duration_sum_ms += snap_stats.error_duration_sum_ms
183
230
  end
184
231
  agg.unique_users_enabled.merge(snap_agg.unique_users_enabled)
185
232
  agg.unique_users_disabled.merge(snap_agg.unique_users_disabled)
@@ -250,7 +297,9 @@ module Toggly
250
297
  variant_stats = {}
251
298
  agg.variant_stats.each do |name, vs|
252
299
  next unless vs.check_count.positive? || vs.request_count.positive? ||
253
- vs.used_count.positive? || vs.viewed_count.positive?
300
+ vs.used_count.positive? || vs.viewed_count.positive? ||
301
+ vs.error_count.positive? || vs.duration_sum_ms.positive? ||
302
+ vs.error_duration_sum_ms.positive?
254
303
 
255
304
  variant_stats[name] = vs.to_wire
256
305
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module Toggly
6
+ module Telemetry
7
+ # Tracks one usage attempt; call {#fail} to mark failure without raising.
8
+ class FeatureUsageScope
9
+ def initialize
10
+ @failed = false
11
+ end
12
+
13
+ def fail
14
+ @failed = true
15
+ end
16
+
17
+ def failed?
18
+ @failed
19
+ end
20
+ end
21
+
22
+ # Runs a usage-scope block and records attempt, duration, and errors.
23
+ module UsageScopeRunner
24
+ module_function
25
+
26
+ def run(handlers, &block)
27
+ record_attempt, record_success, record_failure = handlers
28
+ record_attempt.call
29
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ scope = FeatureUsageScope.new
31
+ outcome_recorded = false
32
+ # $! is the nearest enclosing rescue, not this scope. A clean return
33
+ # inside an outer rescue must not inherit that exception as a failure.
34
+ enclosing_error = $ERROR_INFO
35
+ begin
36
+ result = block.call(scope)
37
+ duration_ms = elapsed_ms(start)
38
+ if scope.failed?
39
+ record_failure.call(duration_ms)
40
+ else
41
+ record_success.call(duration_ms)
42
+ end
43
+ outcome_recorded = true
44
+ result
45
+ rescue StandardError => e
46
+ record_failure.call(elapsed_ms(start))
47
+ outcome_recorded = true
48
+ raise e
49
+ ensure
50
+ # Non-local exits (return/break/throw) and Exception subclasses that
51
+ # bypass rescue StandardError still need an outcome. Only a $! raised
52
+ # by this scope (different from the enclosing rescue) is a failure.
53
+ # Interrupt / SystemExit / SignalException match Python: attempt only.
54
+ unless outcome_recorded
55
+ duration_ms = elapsed_ms(start)
56
+ raised_here = !$ERROR_INFO.equal?(enclosing_error)
57
+ if attempt_only_exception?($ERROR_INFO) && raised_here && !scope.failed?
58
+ # Attempt already recorded; process control exits are not failures.
59
+ elsif scope.failed? || raised_here
60
+ record_failure.call(duration_ms)
61
+ else
62
+ record_success.call(duration_ms)
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def attempt_only_exception?(error)
69
+ error.is_a?(SystemExit) || error.is_a?(Interrupt) || error.is_a?(SignalException)
70
+ end
71
+
72
+ def elapsed_ms(start)
73
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
74
+ ms.negative? ? 0 : ms
75
+ end
76
+ end
77
+ end
78
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "telemetry/grpc_clients"
4
4
  require_relative "telemetry/usage_batcher"
5
+ require_relative "telemetry/usage_scope"
5
6
  require_relative "telemetry/metrics_batcher"
6
7
  require_relative "telemetry/runtime"
7
8
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "1.3.0"
4
+ VERSION = "2.0.0"
5
5
  end
data/proto/usage.proto CHANGED
@@ -44,6 +44,9 @@ message VariantStats {
44
44
  int32 requestCount = 2;
45
45
  int32 usedCount = 3;
46
46
  int32 viewedCount = 4;
47
+ int32 errorCount = 5;
48
+ int64 durationSumMs = 6;
49
+ int64 errorDurationSumMs = 7;
47
50
  }
48
51
 
49
52
  message StatResult {
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.3.0
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-06 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,7 +66,9 @@ 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
71
+ - lib/toggly/telemetry/usage_scope.rb
70
72
  - lib/toggly/user_agent_parser.rb
71
73
  - lib/toggly/variant_allocator.rb
72
74
  - lib/toggly/variant_result.rb
@@ -91,7 +93,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
91
93
  requirements:
92
94
  - - ">="
93
95
  - !ruby/object:Gem::Version
94
- version: 3.2.0
96
+ version: 3.3.0
95
97
  required_rubygems_version: !ruby/object:Gem::Requirement
96
98
  requirements:
97
99
  - - ">="