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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +4 -0
- data/lib/toggly/client.rb +19 -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/grpc_clients.rb +4 -1
- data/lib/toggly/telemetry/pb/usage_pb.rb +1 -1
- data/lib/toggly/telemetry/pb/usage_services_pb.rb +1 -1
- data/lib/toggly/telemetry/runtime.rb +41 -0
- data/lib/toggly/telemetry/system_metrics.rb +243 -0
- data/lib/toggly/telemetry/usage_batcher.rb +54 -5
- data/lib/toggly/telemetry/usage_scope.rb +78 -0
- data/lib/toggly/telemetry.rb +1 -0
- data/lib/toggly/version.rb +1 -1
- data/proto/usage.proto +3 -0
- metadata +5 -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,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
|
-
|
|
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
|
|
@@ -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\"
|
|
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)
|
|
@@ -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(
|
|
8
|
-
|
|
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
|
data/lib/toggly/telemetry.rb
CHANGED
data/lib/toggly/version.rb
CHANGED
data/proto/usage.proto
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,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.
|
|
96
|
+
version: 3.3.0
|
|
95
97
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
98
|
requirements:
|
|
97
99
|
- - ">="
|