toggly 0.2.1 → 0.5.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 +57 -0
- data/README.md +34 -2
- data/lib/toggly/client/cache_telemetry.rb +36 -0
- data/lib/toggly/client/snapshot_support.rb +38 -0
- data/lib/toggly/client.rb +173 -51
- data/lib/toggly/config.rb +39 -1
- data/lib/toggly/context.rb +135 -9
- data/lib/toggly/definition_cache.rb +78 -0
- data/lib/toggly/definitions_provider.rb +72 -18
- data/lib/toggly/evaluation_engine.rb +4 -3
- data/lib/toggly/evaluators/always_off.rb +5 -1
- data/lib/toggly/evaluators/always_on.rb +5 -1
- data/lib/toggly/evaluators/base.rb +21 -3
- data/lib/toggly/evaluators/browser_family.rb +27 -0
- data/lib/toggly/evaluators/browser_language.rb +26 -0
- data/lib/toggly/evaluators/context_property.rb +5 -1
- data/lib/toggly/evaluators/contextual_targeting.rb +4 -0
- data/lib/toggly/evaluators/country.rb +30 -0
- data/lib/toggly/evaluators/device_type.rb +27 -0
- data/lib/toggly/evaluators/operating_system.rb +31 -0
- data/lib/toggly/evaluators/percentage.rb +11 -49
- data/lib/toggly/evaluators/segment_helpers.rb +98 -0
- data/lib/toggly/evaluators/targeting.rb +61 -14
- data/lib/toggly/evaluators/time_window.rb +14 -9
- data/lib/toggly/evaluators/user_claims.rb +27 -0
- data/lib/toggly/feature_definition.rb +1 -2
- data/lib/toggly/http_request_mapper.rb +64 -0
- data/lib/toggly/registry.rb +25 -6
- data/lib/toggly/request_context.rb +77 -0
- data/lib/toggly/sticky_hash.rb +39 -0
- data/lib/toggly/telemetry/grpc_clients.rb +333 -0
- data/lib/toggly/telemetry/metrics_batcher.rb +140 -0
- data/lib/toggly/telemetry/pb/metrics_pb.rb +28 -0
- data/lib/toggly/telemetry/pb/metrics_services_pb.rb +28 -0
- data/lib/toggly/telemetry/pb/usage_pb.rb +27 -0
- data/lib/toggly/telemetry/pb/usage_services_pb.rb +28 -0
- data/lib/toggly/telemetry/runtime.rb +262 -0
- data/lib/toggly/telemetry/runtime_flush.rb +89 -0
- data/lib/toggly/telemetry/usage_batcher.rb +277 -0
- data/lib/toggly/telemetry.rb +37 -0
- data/lib/toggly/user_agent_parser.rb +69 -0
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.rb +16 -1
- data/proto/metrics.proto +60 -0
- data/proto/usage.proto +55 -0
- metadata +29 -3
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runtime_flush"
|
|
4
|
+
|
|
5
|
+
module Toggly
|
|
6
|
+
module Telemetry
|
|
7
|
+
# Owns usage + metrics batchers, flush timers, and process-exit handlers.
|
|
8
|
+
class Runtime
|
|
9
|
+
include RuntimeFlush
|
|
10
|
+
|
|
11
|
+
TIMER_JOIN_TIMEOUT_SECONDS = 2.0
|
|
12
|
+
|
|
13
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors other SDK telemetry config surfaces
|
|
14
|
+
def initialize(
|
|
15
|
+
app_key:,
|
|
16
|
+
environment:,
|
|
17
|
+
metrics_base_url: nil,
|
|
18
|
+
enable_usage_tracking: nil,
|
|
19
|
+
enable_metrics: nil,
|
|
20
|
+
usage_flush_interval: nil,
|
|
21
|
+
metrics_flush_interval: nil,
|
|
22
|
+
instance_name: nil,
|
|
23
|
+
app_version: nil,
|
|
24
|
+
usage_client: nil,
|
|
25
|
+
metrics_client: nil,
|
|
26
|
+
usage_client_provided: false,
|
|
27
|
+
metrics_client_provided: false,
|
|
28
|
+
logger: nil
|
|
29
|
+
)
|
|
30
|
+
# rubocop:enable Metrics/ParameterLists
|
|
31
|
+
has_app_key = !app_key.nil? && !app_key.to_s.empty?
|
|
32
|
+
telemetry_env_disabled = ENV["TOGGLY_DISABLE_TELEMETRY"] == "1"
|
|
33
|
+
default_on = has_app_key && !telemetry_env_disabled
|
|
34
|
+
|
|
35
|
+
@app_key = app_key
|
|
36
|
+
@environment = environment
|
|
37
|
+
@metrics_base_url = normalize_url(metrics_base_url || GrpcClients::DEFAULT_METRICS_BASE_URL)
|
|
38
|
+
@enable_usage = default_on
|
|
39
|
+
@enable_usage = enable_usage_tracking ? true : false unless enable_usage_tracking.nil?
|
|
40
|
+
@enable_metrics = default_on
|
|
41
|
+
@enable_metrics = enable_metrics ? true : false unless enable_metrics.nil?
|
|
42
|
+
@usage_flush_interval = usage_flush_interval.nil? ? GrpcClients::DEFAULT_TELEMETRY_FLUSH_SECONDS : usage_flush_interval.to_f
|
|
43
|
+
@metrics_flush_interval = metrics_flush_interval.nil? ? GrpcClients::DEFAULT_TELEMETRY_FLUSH_SECONDS : metrics_flush_interval.to_f
|
|
44
|
+
@instance_name = instance_name
|
|
45
|
+
@app_version = app_version
|
|
46
|
+
@injected_usage = usage_client
|
|
47
|
+
@injected_metrics = metrics_client
|
|
48
|
+
@usage_client_provided = usage_client_provided
|
|
49
|
+
@metrics_client_provided = metrics_client_provided
|
|
50
|
+
@logger = logger
|
|
51
|
+
|
|
52
|
+
@usage_batcher = nil
|
|
53
|
+
@metrics_batcher = nil
|
|
54
|
+
@clients = nil
|
|
55
|
+
@usage_timer = nil
|
|
56
|
+
@metrics_timer = nil
|
|
57
|
+
@sending_usage = false
|
|
58
|
+
@sending_metrics = false
|
|
59
|
+
@closed = false
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
@timer_cv = ConditionVariable.new
|
|
62
|
+
@process_start_time = Time.now.utc
|
|
63
|
+
@atexit_registered = false
|
|
64
|
+
@warned_missing_grpc = false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def usage_enabled?
|
|
68
|
+
@enable_usage && !@closed
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def metrics_enabled?
|
|
72
|
+
@enable_metrics && !@closed
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def start
|
|
76
|
+
return if @closed
|
|
77
|
+
return unless @enable_usage || @enable_metrics
|
|
78
|
+
|
|
79
|
+
if @usage_client_provided || @metrics_client_provided
|
|
80
|
+
@clients = GrpcClients::Clients.new(usage: @injected_usage, metrics: @injected_metrics)
|
|
81
|
+
elsif !GrpcClients.grpc_available?
|
|
82
|
+
warn_missing_grpc
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if @enable_usage
|
|
86
|
+
@usage_batcher = UsageBatcher.new(
|
|
87
|
+
@app_key,
|
|
88
|
+
@environment,
|
|
89
|
+
instance_name: @instance_name,
|
|
90
|
+
app_version: @app_version,
|
|
91
|
+
process_start_time: @process_start_time
|
|
92
|
+
)
|
|
93
|
+
schedule_usage_flush if @usage_flush_interval.positive?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if @enable_metrics
|
|
97
|
+
@metrics_batcher = MetricsBatcher.new(
|
|
98
|
+
@app_key,
|
|
99
|
+
@environment,
|
|
100
|
+
instance_name: @instance_name
|
|
101
|
+
)
|
|
102
|
+
schedule_metrics_flush if @metrics_flush_interval.positive?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
attach_exit_handlers
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def record_check(feature, enabled, identity = nil, variant: nil, unique_request: false)
|
|
109
|
+
batcher = @mutex.synchronize { @usage_batcher }
|
|
110
|
+
batcher&.record_check(feature, enabled, identity, variant: variant, unique_request: unique_request)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record_usage(feature, identity = nil, variant: "enabled")
|
|
114
|
+
batcher = @mutex.synchronize { @usage_batcher }
|
|
115
|
+
batcher&.record_usage(feature, identity, variant: variant)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def record_view(feature, identity = nil, variant: "enabled")
|
|
119
|
+
batcher = @mutex.synchronize { @usage_batcher }
|
|
120
|
+
batcher&.record_view(feature, identity, variant: variant)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def record_definition_cache_hit
|
|
124
|
+
batcher = @mutex.synchronize { @usage_batcher }
|
|
125
|
+
batcher&.record_definition_cache_hit
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def record_definition_cache_miss
|
|
129
|
+
batcher = @mutex.synchronize { @usage_batcher }
|
|
130
|
+
batcher&.record_definition_cache_miss
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def measure(metric, value, options = nil)
|
|
134
|
+
batcher = @mutex.synchronize { @metrics_batcher }
|
|
135
|
+
batcher&.measure(metric, value, options)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def increment_counter(metric, value = 1.0, options = nil)
|
|
139
|
+
batcher = @mutex.synchronize { @metrics_batcher }
|
|
140
|
+
batcher&.increment_counter(metric, value, options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def observe(metric, value, options = nil)
|
|
144
|
+
batcher = @mutex.synchronize { @metrics_batcher }
|
|
145
|
+
batcher&.observe(metric, value, options)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def close
|
|
149
|
+
timers = nil
|
|
150
|
+
@mutex.synchronize do
|
|
151
|
+
return if @closed
|
|
152
|
+
|
|
153
|
+
@closed = true
|
|
154
|
+
@timer_cv.broadcast
|
|
155
|
+
timers = [@usage_timer, @metrics_timer].compact
|
|
156
|
+
@usage_timer = nil
|
|
157
|
+
@metrics_timer = nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Wait for flush loops (including in-flight sends) — do not Thread#kill.
|
|
161
|
+
timers.each { |thread| join_timer(thread) }
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
flush_all
|
|
165
|
+
ensure
|
|
166
|
+
clients = @clients
|
|
167
|
+
@clients = nil
|
|
168
|
+
@usage_batcher = nil
|
|
169
|
+
@metrics_batcher = nil
|
|
170
|
+
[clients&.usage, clients&.metrics].compact.uniq.each do |side|
|
|
171
|
+
side.close if side.respond_to?(:close)
|
|
172
|
+
rescue StandardError
|
|
173
|
+
# best-effort
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def normalize_url(url)
|
|
181
|
+
u = url.to_s
|
|
182
|
+
u.end_with?("/") ? u : "#{u}/"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def warn_missing_grpc
|
|
186
|
+
return if @warned_missing_grpc
|
|
187
|
+
|
|
188
|
+
@warned_missing_grpc = true
|
|
189
|
+
log_warn(
|
|
190
|
+
"Usage/metrics enabled but the optional grpc gem is not installed. " \
|
|
191
|
+
"Install `grpc` (and google-protobuf) to send telemetry."
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def schedule_usage_flush
|
|
196
|
+
return if @closed || @usage_flush_interval <= 0
|
|
197
|
+
|
|
198
|
+
@usage_timer = Thread.new do
|
|
199
|
+
loop do
|
|
200
|
+
wait_for_interval_or_stop(@usage_flush_interval)
|
|
201
|
+
break if @closed
|
|
202
|
+
|
|
203
|
+
flush_usage
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
@usage_timer.abort_on_exception = false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def schedule_metrics_flush
|
|
210
|
+
return if @closed || @metrics_flush_interval <= 0
|
|
211
|
+
|
|
212
|
+
@metrics_timer = Thread.new do
|
|
213
|
+
loop do
|
|
214
|
+
wait_for_interval_or_stop(@metrics_flush_interval)
|
|
215
|
+
break if @closed
|
|
216
|
+
|
|
217
|
+
flush_metrics
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
@metrics_timer.abort_on_exception = false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Interruptible interval wait — close broadcasts to wake early.
|
|
224
|
+
def wait_for_interval_or_stop(interval)
|
|
225
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + interval
|
|
226
|
+
@mutex.synchronize do
|
|
227
|
+
until @closed
|
|
228
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
229
|
+
break if remaining <= 0
|
|
230
|
+
|
|
231
|
+
@timer_cv.wait(@mutex, remaining)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def join_timer(thread)
|
|
237
|
+
return if thread.nil? || !thread.alive?
|
|
238
|
+
|
|
239
|
+
thread.join(TIMER_JOIN_TIMEOUT_SECONDS)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def attach_exit_handlers
|
|
243
|
+
return if @atexit_registered
|
|
244
|
+
|
|
245
|
+
at_exit do
|
|
246
|
+
close
|
|
247
|
+
rescue StandardError
|
|
248
|
+
# best-effort flush on exit
|
|
249
|
+
end
|
|
250
|
+
@atexit_registered = true
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def log_warn(message)
|
|
254
|
+
@logger&.warn("[Toggly] #{message}")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def log_error(message)
|
|
258
|
+
@logger&.error("[Toggly] #{message}")
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Usage/metrics flush + native gRPC client bootstrap for Runtime.
|
|
6
|
+
module RuntimeFlush
|
|
7
|
+
def flush_usage
|
|
8
|
+
client = nil
|
|
9
|
+
batcher = nil
|
|
10
|
+
snapshot = nil
|
|
11
|
+
payload = nil
|
|
12
|
+
@mutex.synchronize do
|
|
13
|
+
return if @usage_batcher.nil? || @sending_usage
|
|
14
|
+
return if @usage_batcher.empty?
|
|
15
|
+
|
|
16
|
+
ensure_native_clients
|
|
17
|
+
client = @clients&.usage
|
|
18
|
+
return if client.nil? || !client.respond_to?(:send_stats)
|
|
19
|
+
|
|
20
|
+
# Hold a local reference: close() may null @usage_batcher during send.
|
|
21
|
+
batcher = @usage_batcher
|
|
22
|
+
drained = batcher.export_and_reset
|
|
23
|
+
return if drained.nil?
|
|
24
|
+
|
|
25
|
+
payload = drained.payload
|
|
26
|
+
snapshot = drained.snapshot
|
|
27
|
+
@sending_usage = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
client.send_stats(payload)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
log_error("Failed to send usage stats: #{e.message}")
|
|
34
|
+
begin
|
|
35
|
+
batcher&.restore(snapshot)
|
|
36
|
+
rescue StandardError => restore_error
|
|
37
|
+
log_error("Failed to restore usage batch after send failure: #{restore_error.message}")
|
|
38
|
+
end
|
|
39
|
+
ensure
|
|
40
|
+
@mutex.synchronize { @sending_usage = false }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def flush_metrics
|
|
45
|
+
client = nil
|
|
46
|
+
payload = nil
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return if @metrics_batcher.nil? || @sending_metrics
|
|
49
|
+
return if @metrics_batcher.empty?
|
|
50
|
+
|
|
51
|
+
ensure_native_clients
|
|
52
|
+
client = @clients&.metrics
|
|
53
|
+
return if client.nil? || !client.respond_to?(:send_metrics)
|
|
54
|
+
|
|
55
|
+
payload = @metrics_batcher.build_and_reset
|
|
56
|
+
return if payload.nil?
|
|
57
|
+
|
|
58
|
+
@sending_metrics = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
client.send_metrics(payload)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
log_error("Failed to send metrics: #{e.message}")
|
|
65
|
+
ensure
|
|
66
|
+
@mutex.synchronize { @sending_metrics = false }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def flush_all
|
|
71
|
+
flush_usage
|
|
72
|
+
flush_metrics
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def ensure_native_clients
|
|
78
|
+
return if @clients
|
|
79
|
+
return if @usage_client_provided || @metrics_client_provided
|
|
80
|
+
return unless GrpcClients.grpc_available?
|
|
81
|
+
|
|
82
|
+
@clients = GrpcClients.create(@metrics_base_url)
|
|
83
|
+
return if @clients
|
|
84
|
+
|
|
85
|
+
log_warn("Failed to create Toggly gRPC clients; telemetry send disabled")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Telemetry
|
|
5
|
+
# In-memory feature usage aggregator (Usage.SendStats payload shape).
|
|
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)
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_wire
|
|
13
|
+
{
|
|
14
|
+
checkCount: check_count,
|
|
15
|
+
requestCount: request_count,
|
|
16
|
+
usedCount: used_count,
|
|
17
|
+
viewedCount: viewed_count
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clone
|
|
22
|
+
VariantStatsAgg.new(
|
|
23
|
+
check_count: check_count,
|
|
24
|
+
request_count: request_count,
|
|
25
|
+
used_count: used_count,
|
|
26
|
+
viewed_count: viewed_count
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
FeatureUsageAgg = Struct.new(
|
|
32
|
+
:variant_stats,
|
|
33
|
+
:unique_users_enabled,
|
|
34
|
+
:unique_users_disabled,
|
|
35
|
+
:unique_users_used,
|
|
36
|
+
:unique_user_hashes,
|
|
37
|
+
:unique_viewed_user_hashes,
|
|
38
|
+
keyword_init: true
|
|
39
|
+
) do
|
|
40
|
+
def initialize(
|
|
41
|
+
variant_stats: {},
|
|
42
|
+
unique_users_enabled: Set.new,
|
|
43
|
+
unique_users_disabled: Set.new,
|
|
44
|
+
unique_users_used: Set.new,
|
|
45
|
+
unique_user_hashes: Set.new,
|
|
46
|
+
unique_viewed_user_hashes: Set.new
|
|
47
|
+
)
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clone
|
|
52
|
+
FeatureUsageAgg.new(
|
|
53
|
+
variant_stats: variant_stats.transform_values(&:clone),
|
|
54
|
+
unique_users_enabled: unique_users_enabled.dup,
|
|
55
|
+
unique_users_disabled: unique_users_disabled.dup,
|
|
56
|
+
unique_users_used: unique_users_used.dup,
|
|
57
|
+
unique_user_hashes: unique_user_hashes.dup,
|
|
58
|
+
unique_viewed_user_hashes: unique_viewed_user_hashes.dup
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Restoreable copy of drained batcher state.
|
|
64
|
+
BatchSnapshot = Struct.new(
|
|
65
|
+
:per_feature,
|
|
66
|
+
:app_unique,
|
|
67
|
+
:definition_cache_hits,
|
|
68
|
+
:definition_cache_misses,
|
|
69
|
+
keyword_init: true
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Wire payload plus snapshot for failed-send restore.
|
|
73
|
+
DrainedBatch = Struct.new(:payload, :snapshot, keyword_init: true)
|
|
74
|
+
|
|
75
|
+
def initialize(app_key, environment, instance_name: nil, app_version: nil, process_start_time: nil)
|
|
76
|
+
@app_key = app_key
|
|
77
|
+
@environment = environment
|
|
78
|
+
@instance_name = instance_name
|
|
79
|
+
@app_version = app_version
|
|
80
|
+
@process_start_time = process_start_time || Time.now.utc
|
|
81
|
+
@per_feature = {}
|
|
82
|
+
@app_unique = Set.new
|
|
83
|
+
@definition_cache_hits = 0
|
|
84
|
+
@definition_cache_misses = 0
|
|
85
|
+
@mutex = Mutex.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def record_definition_cache_hit
|
|
89
|
+
@mutex.synchronize { @definition_cache_hits += 1 }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def record_definition_cache_miss
|
|
93
|
+
@mutex.synchronize { @definition_cache_misses += 1 }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record_check(feature, enabled, identity = nil, variant: nil, unique_request: false)
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
agg = get_feature(feature)
|
|
99
|
+
name = if variant.nil?
|
|
100
|
+
enabled ? "enabled" : "disabled"
|
|
101
|
+
else
|
|
102
|
+
variant.to_s
|
|
103
|
+
end
|
|
104
|
+
stats = get_variant(agg, name)
|
|
105
|
+
stats.check_count += 1
|
|
106
|
+
stats.request_count += 1 if unique_request
|
|
107
|
+
|
|
108
|
+
if identity && !identity.to_s.empty?
|
|
109
|
+
hashed = GrpcClients.hash_identity(identity)
|
|
110
|
+
@app_unique.add(hashed)
|
|
111
|
+
if enabled
|
|
112
|
+
agg.unique_users_enabled.add(hashed)
|
|
113
|
+
else
|
|
114
|
+
agg.unique_users_disabled.add(hashed)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def record_usage(feature, identity = nil, variant: "enabled")
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
agg = get_feature(feature)
|
|
123
|
+
get_variant(agg, variant.to_s).used_count += 1
|
|
124
|
+
track_identity(identity, agg.unique_users_used)
|
|
125
|
+
agg.unique_user_hashes.add(GrpcClients.hash_identity(identity)) if identity && !identity.to_s.empty?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def record_view(feature, identity = nil, variant: "enabled")
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
agg = get_feature(feature)
|
|
132
|
+
get_variant(agg, variant.to_s).viewed_count += 1
|
|
133
|
+
if identity && !identity.to_s.empty?
|
|
134
|
+
hashed = GrpcClients.hash_identity(identity)
|
|
135
|
+
@app_unique.add(hashed)
|
|
136
|
+
agg.unique_viewed_user_hashes.add(hashed)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def empty?
|
|
142
|
+
@mutex.synchronize { empty_unlocked? }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build the SendStats payload, clear pending state, and return a restore snapshot.
|
|
146
|
+
#
|
|
147
|
+
# @return [DrainedBatch, nil]
|
|
148
|
+
def export_and_reset
|
|
149
|
+
@mutex.synchronize do
|
|
150
|
+
return nil if empty_unlocked?
|
|
151
|
+
|
|
152
|
+
snapshot = clone_snapshot_unlocked
|
|
153
|
+
payload = build_payload_unlocked
|
|
154
|
+
clear_unlocked
|
|
155
|
+
DrainedBatch.new(payload: payload, snapshot: snapshot)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_and_reset
|
|
160
|
+
drained = export_and_reset
|
|
161
|
+
drained&.payload
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Merge a drained snapshot after send_stats failure (additive).
|
|
165
|
+
#
|
|
166
|
+
# @param snapshot [BatchSnapshot, nil]
|
|
167
|
+
def restore(snapshot)
|
|
168
|
+
return if snapshot.nil?
|
|
169
|
+
|
|
170
|
+
@mutex.synchronize do
|
|
171
|
+
@definition_cache_hits += snapshot.definition_cache_hits
|
|
172
|
+
@definition_cache_misses += snapshot.definition_cache_misses
|
|
173
|
+
@app_unique.merge(snapshot.app_unique)
|
|
174
|
+
|
|
175
|
+
snapshot.per_feature.each do |feature, snap_agg|
|
|
176
|
+
agg = get_feature(feature)
|
|
177
|
+
snap_agg.variant_stats.each do |name, snap_stats|
|
|
178
|
+
stats = get_variant(agg, name)
|
|
179
|
+
stats.check_count += snap_stats.check_count
|
|
180
|
+
stats.request_count += snap_stats.request_count
|
|
181
|
+
stats.used_count += snap_stats.used_count
|
|
182
|
+
stats.viewed_count += snap_stats.viewed_count
|
|
183
|
+
end
|
|
184
|
+
agg.unique_users_enabled.merge(snap_agg.unique_users_enabled)
|
|
185
|
+
agg.unique_users_disabled.merge(snap_agg.unique_users_disabled)
|
|
186
|
+
agg.unique_users_used.merge(snap_agg.unique_users_used)
|
|
187
|
+
agg.unique_user_hashes.merge(snap_agg.unique_user_hashes)
|
|
188
|
+
agg.unique_viewed_user_hashes.merge(snap_agg.unique_viewed_user_hashes)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def empty_unlocked?
|
|
196
|
+
@per_feature.empty? &&
|
|
197
|
+
@app_unique.empty? &&
|
|
198
|
+
@definition_cache_hits.zero? &&
|
|
199
|
+
@definition_cache_misses.zero?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def clone_snapshot_unlocked
|
|
203
|
+
BatchSnapshot.new(
|
|
204
|
+
per_feature: @per_feature.transform_values(&:clone),
|
|
205
|
+
app_unique: @app_unique.dup,
|
|
206
|
+
definition_cache_hits: @definition_cache_hits,
|
|
207
|
+
definition_cache_misses: @definition_cache_misses
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def build_payload_unlocked
|
|
212
|
+
payload = {
|
|
213
|
+
appKey: @app_key,
|
|
214
|
+
environment: @environment,
|
|
215
|
+
time: GrpcClients.to_protobuf_timestamp,
|
|
216
|
+
stats: drain_feature_stats,
|
|
217
|
+
totalUniqueUsers: @app_unique.size,
|
|
218
|
+
uniqueUserHashes: @app_unique.to_a,
|
|
219
|
+
processStartTime: GrpcClients.to_protobuf_timestamp(@process_start_time)
|
|
220
|
+
}
|
|
221
|
+
payload[:instanceName] = @instance_name if @instance_name
|
|
222
|
+
payload[:appVersion] = @app_version if @app_version
|
|
223
|
+
payload[:definitionCacheHits] = @definition_cache_hits if @definition_cache_hits.positive?
|
|
224
|
+
payload[:definitionCacheMisses] = @definition_cache_misses if @definition_cache_misses.positive?
|
|
225
|
+
payload
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def clear_unlocked
|
|
229
|
+
@per_feature = {}
|
|
230
|
+
@app_unique = Set.new
|
|
231
|
+
@definition_cache_hits = 0
|
|
232
|
+
@definition_cache_misses = 0
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def drain_feature_stats
|
|
236
|
+
@per_feature.map do |feature, agg|
|
|
237
|
+
{
|
|
238
|
+
feature: feature,
|
|
239
|
+
uniqueContextIdentifierEnabledCount: agg.unique_users_enabled.size,
|
|
240
|
+
uniqueContextIdentifierDisabledCount: agg.unique_users_disabled.size,
|
|
241
|
+
uniqueUsersUsedCount: agg.unique_users_used.size,
|
|
242
|
+
uniqueUserHashes: agg.unique_user_hashes.to_a,
|
|
243
|
+
uniqueViewedUserHashes: agg.unique_viewed_user_hashes.to_a,
|
|
244
|
+
variantStats: wire_variant_stats(agg)
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def wire_variant_stats(agg)
|
|
250
|
+
variant_stats = {}
|
|
251
|
+
agg.variant_stats.each do |name, vs|
|
|
252
|
+
next unless vs.check_count.positive? || vs.request_count.positive? ||
|
|
253
|
+
vs.used_count.positive? || vs.viewed_count.positive?
|
|
254
|
+
|
|
255
|
+
variant_stats[name] = vs.to_wire
|
|
256
|
+
end
|
|
257
|
+
variant_stats
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def get_feature(feature)
|
|
261
|
+
@per_feature[feature] ||= FeatureUsageAgg.new
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def get_variant(agg, variant)
|
|
265
|
+
agg.variant_stats[variant] ||= VariantStatsAgg.new
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def track_identity(identity, into)
|
|
269
|
+
return if identity.nil? || identity.to_s.empty?
|
|
270
|
+
|
|
271
|
+
hashed = GrpcClients.hash_identity(identity)
|
|
272
|
+
@app_unique.add(hashed)
|
|
273
|
+
into.add(hashed)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "telemetry/grpc_clients"
|
|
4
|
+
require_relative "telemetry/usage_batcher"
|
|
5
|
+
require_relative "telemetry/metrics_batcher"
|
|
6
|
+
require_relative "telemetry/runtime"
|
|
7
|
+
|
|
8
|
+
module Toggly
|
|
9
|
+
# Usage and business-metrics telemetry (optional gRPC transport).
|
|
10
|
+
module Telemetry
|
|
11
|
+
DEFAULT_METRICS_BASE_URL = GrpcClients::DEFAULT_METRICS_BASE_URL
|
|
12
|
+
DEFAULT_TELEMETRY_FLUSH_SECONDS = GrpcClients::DEFAULT_TELEMETRY_FLUSH_SECONDS
|
|
13
|
+
GRPC_USER_AGENT_METADATA_KEY = GrpcClients::GRPC_USER_AGENT_METADATA_KEY
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def hash_identity(identity)
|
|
18
|
+
GrpcClients.hash_identity(identity)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def feature_stat_from_payload(payload)
|
|
22
|
+
GrpcClients.feature_stat_from_payload(payload)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def metric_stat_from_payload(payload)
|
|
26
|
+
GrpcClients.metric_stat_from_payload(payload)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def grpc_available?
|
|
30
|
+
GrpcClients.grpc_available?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolve_user_agent(override = nil)
|
|
34
|
+
GrpcClients.resolve_user_agent(override)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|