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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/README.md +34 -2
  4. data/lib/toggly/client/cache_telemetry.rb +36 -0
  5. data/lib/toggly/client/snapshot_support.rb +38 -0
  6. data/lib/toggly/client.rb +173 -51
  7. data/lib/toggly/config.rb +39 -1
  8. data/lib/toggly/context.rb +135 -9
  9. data/lib/toggly/definition_cache.rb +78 -0
  10. data/lib/toggly/definitions_provider.rb +72 -18
  11. data/lib/toggly/evaluation_engine.rb +4 -3
  12. data/lib/toggly/evaluators/always_off.rb +5 -1
  13. data/lib/toggly/evaluators/always_on.rb +5 -1
  14. data/lib/toggly/evaluators/base.rb +21 -3
  15. data/lib/toggly/evaluators/browser_family.rb +27 -0
  16. data/lib/toggly/evaluators/browser_language.rb +26 -0
  17. data/lib/toggly/evaluators/context_property.rb +5 -1
  18. data/lib/toggly/evaluators/contextual_targeting.rb +4 -0
  19. data/lib/toggly/evaluators/country.rb +30 -0
  20. data/lib/toggly/evaluators/device_type.rb +27 -0
  21. data/lib/toggly/evaluators/operating_system.rb +31 -0
  22. data/lib/toggly/evaluators/percentage.rb +11 -49
  23. data/lib/toggly/evaluators/segment_helpers.rb +98 -0
  24. data/lib/toggly/evaluators/targeting.rb +61 -14
  25. data/lib/toggly/evaluators/time_window.rb +14 -9
  26. data/lib/toggly/evaluators/user_claims.rb +27 -0
  27. data/lib/toggly/feature_definition.rb +1 -2
  28. data/lib/toggly/http_request_mapper.rb +64 -0
  29. data/lib/toggly/registry.rb +25 -6
  30. data/lib/toggly/request_context.rb +77 -0
  31. data/lib/toggly/sticky_hash.rb +39 -0
  32. data/lib/toggly/telemetry/grpc_clients.rb +333 -0
  33. data/lib/toggly/telemetry/metrics_batcher.rb +140 -0
  34. data/lib/toggly/telemetry/pb/metrics_pb.rb +28 -0
  35. data/lib/toggly/telemetry/pb/metrics_services_pb.rb +28 -0
  36. data/lib/toggly/telemetry/pb/usage_pb.rb +27 -0
  37. data/lib/toggly/telemetry/pb/usage_services_pb.rb +28 -0
  38. data/lib/toggly/telemetry/runtime.rb +262 -0
  39. data/lib/toggly/telemetry/runtime_flush.rb +89 -0
  40. data/lib/toggly/telemetry/usage_batcher.rb +277 -0
  41. data/lib/toggly/telemetry.rb +37 -0
  42. data/lib/toggly/user_agent_parser.rb +69 -0
  43. data/lib/toggly/version.rb +1 -1
  44. data/lib/toggly.rb +16 -1
  45. data/proto/metrics.proto +60 -0
  46. data/proto/usage.proto +55 -0
  47. 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