toggly 0.3.0 → 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.
@@ -14,6 +14,9 @@ end
14
14
  module Toggly
15
15
  # Provider for fetching feature definitions from Toggly API.
16
16
  class DefinitionsProvider
17
+ # Result of one HTTP definitions fetch with cache telemetry outcome.
18
+ FetchResult = Struct.new(:definitions, :cache_outcome, keyword_init: true)
19
+
17
20
  # Fallback HTTP refresh interval when WebSocket is connected (20 minutes)
18
21
  FALLBACK_REFRESH_INTERVAL = 20 * 60
19
22
 
@@ -32,6 +35,7 @@ module Toggly
32
35
  @on_definitions_updated = on_definitions_updated
33
36
  @etag = nil
34
37
  @last_modified = nil
38
+ @last_ts = 0
35
39
 
36
40
  # WebSocket state
37
41
  @ws = nil
@@ -44,11 +48,11 @@ module Toggly
44
48
  # Fetch definitions from the API
45
49
  #
46
50
  # @param force [Boolean] Force fetch even if cached
47
- # @return [Hash, nil] Hash of definitions, or nil if not modified
51
+ # @return [FetchResult] definitions (Hash or nil) plus :hit / :miss outcome
48
52
  # @raise [NetworkError] On network failures
49
53
  # @raise [DefinitionsError] On API errors
50
54
  def fetch(force: false)
51
- return nil if @config.offline_mode?
55
+ return FetchResult.new(definitions: nil, cache_outcome: :hit) if @config.offline_mode?
52
56
 
53
57
  uri = URI.parse(@config.definitions_endpoint)
54
58
  http = build_http(uri)
@@ -60,6 +64,8 @@ module Toggly
60
64
  raise NetworkError, "Request timeout: #{e.message}"
61
65
  rescue SocketError, Errno::ECONNREFUSED => e
62
66
  raise NetworkError, "Connection failed: #{e.message}"
67
+ rescue NetworkError, DefinitionsError
68
+ raise
63
69
  rescue StandardError => e
64
70
  raise NetworkError, "Request failed: #{e.message}"
65
71
  end
@@ -68,6 +74,7 @@ module Toggly
68
74
  def reset_cache
69
75
  @etag = nil
70
76
  @last_modified = nil
77
+ @last_ts = 0
71
78
  end
72
79
 
73
80
  # Check whether the periodic refresh should be skipped because the
@@ -166,32 +173,81 @@ module Toggly
166
173
  end
167
174
 
168
175
  def handle_response(response)
169
- case response.code.to_i
170
- when 200
171
- parse_definitions(response)
172
- when 304
173
- # Not modified
176
+ status = response.code.to_i
177
+ response_etag = response["ETag"]
178
+ response_lm = response["Last-Modified"]
179
+ kind = DefinitionCache.classify_http(
180
+ status,
181
+ @etag,
182
+ response_etag,
183
+ existing_last_modified: @last_modified,
184
+ response_last_modified: response_lm
185
+ )
186
+
187
+ case kind
188
+ when :not_modified
174
189
  log_debug("Definitions not modified")
175
- nil
190
+ FetchResult.new(definitions: nil, cache_outcome: :hit)
191
+ when :same_revision
192
+ log_debug("Definitions revision matches existing (ETag or Last-Modified)")
193
+ store_revision_headers(response_etag, response_lm)
194
+ FetchResult.new(definitions: nil, cache_outcome: :hit)
195
+ when :new_content
196
+ handle_new_content(response, response_etag, response_lm)
197
+ when :error_status
198
+ handle_error_status(status, response)
199
+ end
200
+ end
201
+
202
+ def handle_new_content(response, response_etag, response_lm)
203
+ data = JSON.parse(response.body)
204
+ signed_ts = extract_signed_timestamp(data)
205
+ if DefinitionCache.cached_signed_timestamp?(@last_ts, signed_ts)
206
+ log_debug("Definitions signed timestamp is not newer than cached revision")
207
+ store_revision_headers(response_etag, response_lm)
208
+ return FetchResult.new(definitions: nil, cache_outcome: :hit)
209
+ end
210
+
211
+ definitions = parse_features(data)
212
+ store_revision_headers(response_etag, response_lm)
213
+ @last_ts = signed_ts if signed_ts&.positive?
214
+ FetchResult.new(definitions: definitions, cache_outcome: :miss)
215
+ rescue JSON::ParserError => e
216
+ raise DefinitionsError, "Failed to parse definitions: #{e.message}"
217
+ end
218
+
219
+ def handle_error_status(status, response)
220
+ case status
176
221
  when 401, 403
177
- raise DefinitionsError, "Authentication failed: #{response.code}"
222
+ raise DefinitionsError, "Authentication failed: #{status}"
178
223
  when 404
179
224
  raise DefinitionsError, "Definitions not found (check app_key and environment)"
180
225
  else
181
226
  raise NetworkError.new(
182
- "API error: #{response.code}",
183
- status_code: response.code.to_i,
227
+ "API error: #{status}",
228
+ status_code: status,
184
229
  response_body: response.body
185
230
  )
186
231
  end
187
232
  end
188
233
 
189
- def parse_definitions(response)
190
- # Cache headers for conditional requests
191
- @etag = response["ETag"]
192
- @last_modified = response["Last-Modified"]
234
+ def store_revision_headers(etag, last_modified)
235
+ @etag = etag if etag && !etag.empty?
236
+ @last_modified = last_modified if last_modified && !last_modified.empty?
237
+ end
193
238
 
194
- data = JSON.parse(response.body)
239
+ def extract_signed_timestamp(data)
240
+ return nil unless data.is_a?(Hash)
241
+
242
+ raw = data["timestamp"]
243
+ return nil if raw.nil?
244
+
245
+ Integer(raw)
246
+ rescue ArgumentError, TypeError
247
+ nil
248
+ end
249
+
250
+ def parse_features(data)
195
251
  features = if data.is_a?(Hash)
196
252
  data["defs"] || data["features"] || data
197
253
  else
@@ -209,8 +265,6 @@ module Toggly
209
265
  else
210
266
  raise DefinitionsError, "Invalid definitions format"
211
267
  end
212
- rescue JSON::ParserError => e
213
- raise DefinitionsError, "Failed to parse definitions: #{e.message}"
214
268
  end
215
269
 
216
270
  def build_websocket_url
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Toggly
6
+ module Telemetry
7
+ # Optional gRPC transport helpers for usage and metrics telemetry.
8
+ module GrpcClients
9
+ DEFAULT_METRICS_BASE_URL = "https://app.toggly.io/"
10
+ DEFAULT_TELEMETRY_FLUSH_SECONDS = 60.0
11
+
12
+ # HTTP/2 metadata is case-insensitive; .NET/Go/Node send ``UA``.
13
+ # The Ruby grpc gem lowercases keys, so we send ``ua`` with the same
14
+ # user-agent semantics.
15
+ GRPC_USER_AGENT_METADATA_KEY = "ua"
16
+
17
+ module_function
18
+
19
+ # FNV-1a 32-bit as signed int32 (UTF-8 bytes; matches Go/Node/Python).
20
+ #
21
+ # @param identity [String]
22
+ # @return [Integer] signed int32
23
+ def hash_identity(identity)
24
+ h = 2_166_136_261
25
+ identity.to_s.encode("UTF-8").bytes.each do |byte|
26
+ h ^= byte
27
+ h = (h * 16_777_619) & 0xFFFFFFFF
28
+ end
29
+ h > 0x7FFFFFFF ? h - 0x100000000 : h
30
+ end
31
+
32
+ # @param time [Time, nil]
33
+ # @return [Hash{Symbol => Integer}] protobuf Timestamp shape
34
+ def to_protobuf_timestamp(time = nil)
35
+ t = time || Time.now.utc
36
+ t = t.utc
37
+ ms = (t.to_f * 1000).to_i
38
+ {
39
+ seconds: ms / 1000,
40
+ nanos: (ms % 1000) * 1_000_000
41
+ }
42
+ end
43
+
44
+ # Build a protobuf Timestamp from a batcher hash. Nested message fields are
45
+ # nil until assigned — callers must assign the returned object, not mutate
46
+ # ``msg.time.seconds``.
47
+ #
48
+ # @param timestamp_hash [Hash, nil]
49
+ # @return [Google::Protobuf::Timestamp, nil]
50
+ def build_timestamp(timestamp_hash)
51
+ return nil unless timestamp_hash.is_a?(Hash)
52
+
53
+ require "google/protobuf/timestamp_pb"
54
+ Google::Protobuf::Timestamp.new(
55
+ seconds: timestamp_hash[:seconds].to_i,
56
+ nanos: timestamp_hash[:nanos].to_i
57
+ )
58
+ end
59
+
60
+ # @param base_url [String]
61
+ # @return [String] host:port for a gRPC channel
62
+ def grpc_target(base_url)
63
+ raw = base_url.to_s.strip
64
+ raw = "https://#{raw}" unless raw.include?("://")
65
+ uri = begin
66
+ URI.parse(raw)
67
+ rescue URI::InvalidURIError
68
+ nil
69
+ end
70
+ host = if uri
71
+ uri.host || uri.path
72
+ else
73
+ raw.sub(%r{\Ahttps?://}i, "").delete_suffix("/")
74
+ end
75
+ host = host.to_s.delete_suffix("/")
76
+ host = "app.toggly.io:443" if host.empty?
77
+ host = "#{host}:443" unless host.include?(":")
78
+ host
79
+ end
80
+
81
+ # @param override [String, nil]
82
+ # @return [String]
83
+ def resolve_user_agent(override = nil)
84
+ override || "toggly-ruby/#{Toggly::VERSION}"
85
+ end
86
+
87
+ # @return [Boolean]
88
+ def grpc_available?
89
+ return @grpc_available unless @grpc_available.nil?
90
+
91
+ require "grpc"
92
+ require_relative "pb/usage_services_pb"
93
+ require_relative "pb/metrics_services_pb"
94
+ @grpc_available = true
95
+ rescue LoadError
96
+ @grpc_available = false
97
+ end
98
+
99
+ # Reset cached availability (tests only).
100
+ def reset_grpc_available!
101
+ @grpc_available = nil
102
+ end
103
+
104
+ # Convert a batcher dict into Usage.FeatureStat.
105
+ #
106
+ # @param payload [Hash]
107
+ # @return [Toggly::Telemetry::Pb::Usage::FeatureStat]
108
+ def feature_stat_from_payload(payload)
109
+ NativeUsageClient.feature_stat_from_payload(payload)
110
+ end
111
+
112
+ # Convert a batcher dict into Metrics.MetricStat.
113
+ #
114
+ # @param payload [Hash]
115
+ # @return [Toggly::Telemetry::Pb::Metrics::MetricStat]
116
+ def metric_stat_from_payload(payload)
117
+ NativeMetricsClient.metric_stat_from_payload(payload)
118
+ end
119
+
120
+ # @param metrics_base_url [String]
121
+ # @param user_agent [String, nil]
122
+ # @param timeout [Numeric]
123
+ # @return [Clients, nil]
124
+ def create(metrics_base_url, user_agent: nil, timeout: 10.0)
125
+ return nil unless grpc_available?
126
+
127
+ require "grpc"
128
+
129
+ target = grpc_target(metrics_base_url)
130
+ channel = GRPC::Core::Channel.new(target, {}, GRPC::Core::ChannelCredentials.new)
131
+ shared = SharedChannel.new(channel)
132
+ ua = resolve_user_agent(user_agent)
133
+ default_meta = { GRPC_USER_AGENT_METADATA_KEY => ua }
134
+
135
+ usage_stub = Pb::Usage::Usage::Stub.new(target, GRPC::Core::ChannelCredentials.new,
136
+ channel_override: channel)
137
+ metrics_stub = Pb::Metrics::Metrics::Stub.new(target, GRPC::Core::ChannelCredentials.new,
138
+ channel_override: channel)
139
+
140
+ Clients.new(
141
+ usage: NativeUsageClient.new(usage_stub, shared, default_meta, timeout),
142
+ metrics: NativeMetricsClient.new(metrics_stub, shared, default_meta, timeout)
143
+ )
144
+ end
145
+
146
+ # Paired usage + metrics clients.
147
+ Clients = Struct.new(:usage, :metrics, keyword_init: true)
148
+
149
+ # Shared channel closer (close once).
150
+ class SharedChannel
151
+ def initialize(channel)
152
+ @channel = channel
153
+ @closed = false
154
+ @mutex = Mutex.new
155
+ end
156
+
157
+ def close
158
+ @mutex.synchronize do
159
+ return if @closed
160
+
161
+ @closed = true
162
+ @channel.close
163
+ end
164
+ end
165
+ end
166
+
167
+ # Converts batcher hashes into Usage.FeatureStat and sends via stub.
168
+ class NativeUsageClient
169
+ def initialize(stub, shared, default_metadata, timeout)
170
+ @stub = stub
171
+ @shared = shared
172
+ @default_metadata = default_metadata.transform_keys(&:to_s)
173
+ @timeout = timeout
174
+ end
175
+
176
+ def send_stats(request, metadata: nil)
177
+ meta = @default_metadata.merge((metadata || {}).transform_keys(&:to_s))
178
+ msg = self.class.feature_stat_from_payload(request)
179
+ @stub.send_stats(msg, metadata: meta.to_a, deadline: Time.now + @timeout)
180
+ end
181
+
182
+ def close
183
+ @shared.close
184
+ end
185
+
186
+ # @param payload [Hash]
187
+ # @return [Toggly::Telemetry::Pb::Usage::FeatureStat]
188
+ def self.feature_stat_from_payload(payload)
189
+ require_relative "pb/usage_pb"
190
+
191
+ msg = Pb::Usage::FeatureStat.new(
192
+ appKey: payload[:appKey].to_s,
193
+ environment: payload[:environment].to_s,
194
+ totalUniqueUsers: payload[:totalUniqueUsers].to_i,
195
+ uniqueUserHashes: Array(payload[:uniqueUserHashes]).map(&:to_i)
196
+ )
197
+ apply_feature_stat_metadata(msg, payload)
198
+ Array(payload[:stats]).each do |stat|
199
+ next unless stat.is_a?(Hash)
200
+
201
+ msg.stats << stat_message_from_hash(stat)
202
+ end
203
+ msg
204
+ end
205
+
206
+ def self.apply_feature_stat_metadata(msg, payload)
207
+ timestamp = GrpcClients.build_timestamp(payload[:time])
208
+ msg.time = timestamp if timestamp
209
+ msg.instanceName = payload[:instanceName].to_s if payload[:instanceName]
210
+ msg.appVersion = payload[:appVersion].to_s if payload[:appVersion]
211
+ process_start = GrpcClients.build_timestamp(payload[:processStartTime])
212
+ msg.processStartTime = process_start if process_start
213
+ msg.definitionCacheHits = payload[:definitionCacheHits].to_i if payload[:definitionCacheHits]
214
+ msg.definitionCacheMisses = payload[:definitionCacheMisses].to_i if payload[:definitionCacheMisses]
215
+ end
216
+ private_class_method :apply_feature_stat_metadata
217
+
218
+ def self.stat_message_from_hash(stat)
219
+ sm = Pb::Usage::StatMessage.new(
220
+ feature: stat[:feature].to_s,
221
+ uniqueContextIdentifierEnabledCount: stat[:uniqueContextIdentifierEnabledCount].to_i,
222
+ uniqueContextIdentifierDisabledCount: stat[:uniqueContextIdentifierDisabledCount].to_i,
223
+ uniqueUsersUsedCount: stat[:uniqueUsersUsedCount].to_i,
224
+ uniqueUserHashes: Array(stat[:uniqueUserHashes]).map(&:to_i),
225
+ uniqueViewedUserHashes: Array(stat[:uniqueViewedUserHashes]).map(&:to_i)
226
+ )
227
+ (stat[:variantStats] || {}).each do |name, vs|
228
+ next unless vs.is_a?(Hash)
229
+
230
+ sm.variantStats[name.to_s] = Pb::Usage::VariantStats.new(
231
+ checkCount: vs[:checkCount].to_i,
232
+ requestCount: vs[:requestCount].to_i,
233
+ usedCount: vs[:usedCount].to_i,
234
+ viewedCount: vs[:viewedCount].to_i
235
+ )
236
+ end
237
+ sm
238
+ end
239
+ private_class_method :stat_message_from_hash
240
+ end
241
+
242
+ # Converts batcher hashes into Metrics.MetricStat and sends via stub.
243
+ class NativeMetricsClient
244
+ def initialize(stub, shared, default_metadata, timeout)
245
+ @stub = stub
246
+ @shared = shared
247
+ @default_metadata = default_metadata.transform_keys(&:to_s)
248
+ @timeout = timeout
249
+ end
250
+
251
+ def send_metrics(request, metadata: nil)
252
+ meta = @default_metadata.merge((metadata || {}).transform_keys(&:to_s))
253
+ msg = self.class.metric_stat_from_payload(request)
254
+ @stub.send_metrics(msg, metadata: meta.to_a, deadline: Time.now + @timeout)
255
+ end
256
+
257
+ def close
258
+ @shared.close
259
+ end
260
+
261
+ # @param payload [Hash]
262
+ # @return [Toggly::Telemetry::Pb::Metrics::MetricStat]
263
+ def self.metric_stat_from_payload(payload)
264
+ require_relative "pb/metrics_pb"
265
+
266
+ msg = Pb::Metrics::MetricStat.new(
267
+ appKey: payload[:appKey].to_s,
268
+ environment: payload[:environment].to_s
269
+ )
270
+ apply_metric_stat_metadata(msg, payload)
271
+ append_metric_stats(msg, payload[:stats])
272
+ append_metric_counters(msg, payload[:counters])
273
+ append_metric_observations(msg, payload[:observations])
274
+ msg
275
+ end
276
+
277
+ def self.apply_metric_stat_metadata(msg, payload)
278
+ timestamp = GrpcClients.build_timestamp(payload[:time])
279
+ msg.time = timestamp if timestamp
280
+ msg.instanceName = payload[:instanceName].to_s if payload[:instanceName]
281
+ end
282
+ private_class_method :apply_metric_stat_metadata
283
+
284
+ def self.append_metric_stats(msg, items)
285
+ Array(items).each do |item|
286
+ next unless item.is_a?(Hash)
287
+
288
+ sm = Pb::Metrics::MetricStatMessage.new(metric: item[:metric].to_s)
289
+ sm.feature = item[:feature].to_s if item[:feature]
290
+ fill_variant_values(sm, item[:variantValues])
291
+ msg.stats << sm
292
+ end
293
+ end
294
+ private_class_method :append_metric_stats
295
+
296
+ def self.append_metric_counters(msg, items)
297
+ Array(items).each do |item|
298
+ next unless item.is_a?(Hash)
299
+
300
+ cm = Pb::Metrics::MetricCounterMessage.new(metric: item[:metric].to_s)
301
+ cm.feature = item[:feature].to_s if item[:feature]
302
+ fill_variant_values(cm, item[:variantValues])
303
+ msg.counters << cm
304
+ end
305
+ end
306
+ private_class_method :append_metric_counters
307
+
308
+ def self.append_metric_observations(msg, items)
309
+ Array(items).each do |item|
310
+ next unless item.is_a?(Hash)
311
+
312
+ om = Pb::Metrics::MetricObservationMessage.new(metric: item[:metric].to_s)
313
+ obs_ts = GrpcClients.build_timestamp(item[:time])
314
+ om.time = obs_ts if obs_ts
315
+ om.feature = item[:feature].to_s if item[:feature]
316
+ fill_variant_values(om, item[:variantValues])
317
+ msg.observations << om
318
+ end
319
+ end
320
+ private_class_method :append_metric_observations
321
+
322
+ def self.fill_variant_values(target, variant_values)
323
+ return unless variant_values.is_a?(Hash)
324
+
325
+ variant_values.each do |name, value|
326
+ target.variantValues[name.to_s] = value.to_f
327
+ end
328
+ end
329
+ private_class_method :fill_variant_values
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Telemetry
5
+ # Optional feature/variant correlation for a metric sample.
6
+ MetricsFeatureOptions = Struct.new(:feature, :variant, keyword_init: true)
7
+
8
+ # In-memory business metrics aggregator (Metrics.SendMetrics payload shape).
9
+ class MetricsBatcher
10
+ def initialize(app_key, environment, instance_name: nil)
11
+ @app_key = app_key
12
+ @environment = environment
13
+ @instance_name = instance_name
14
+ @measures = {}
15
+ @counters = {}
16
+ @observations = []
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def measure(metric, value, options = nil)
21
+ @mutex.synchronize { add_to_map(@measures, metric, value.to_f, options) }
22
+ end
23
+
24
+ def increment_counter(metric, value = 1.0, options = nil)
25
+ @mutex.synchronize { add_to_map(@counters, metric, value.to_f, options) }
26
+ end
27
+
28
+ def observe(metric, value, options = nil)
29
+ @mutex.synchronize do
30
+ opts = normalize_options(options)
31
+ variant = opts&.variant && !opts.variant.to_s.empty? ? opts.variant.to_s : "enabled"
32
+ feature = opts&.feature
33
+ @observations << {
34
+ time: Time.now.utc,
35
+ metric: metric.to_s,
36
+ feature: feature,
37
+ variant: variant,
38
+ value: value.to_f
39
+ }
40
+ end
41
+ end
42
+
43
+ def empty?
44
+ @mutex.synchronize { @measures.empty? && @counters.empty? && @observations.empty? }
45
+ end
46
+
47
+ def build_and_reset
48
+ @mutex.synchronize do
49
+ return nil if @measures.empty? && @counters.empty? && @observations.empty?
50
+
51
+ payload = {
52
+ appKey: @app_key,
53
+ environment: @environment,
54
+ time: GrpcClients.to_protobuf_timestamp,
55
+ stats: drain_map(@measures),
56
+ counters: drain_map(@counters),
57
+ observations: drain_observations
58
+ }
59
+ payload[:instanceName] = @instance_name if @instance_name
60
+ payload
61
+ end
62
+ end
63
+
64
+ def self.options_from_hash(options)
65
+ return nil if options.nil?
66
+ return options if options.is_a?(MetricsFeatureOptions)
67
+
68
+ MetricsFeatureOptions.new(
69
+ feature: options[:feature] || options["feature"],
70
+ variant: options[:variant] || options["variant"]
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def normalize_options(options)
77
+ self.class.options_from_hash(options)
78
+ end
79
+
80
+ def metric_key(metric, feature)
81
+ [metric.to_s, feature.to_s]
82
+ end
83
+
84
+ def add_to_map(store, metric, value, options)
85
+ opts = normalize_options(options)
86
+ variant = "enabled"
87
+ feature = nil
88
+ if opts
89
+ variant = opts.variant.to_s unless opts.variant.nil? || opts.variant.to_s.empty?
90
+ feature = opts.feature
91
+ end
92
+ key = metric_key(metric, feature)
93
+ variants = store[key] ||= {}
94
+ variants[variant] = variants.fetch(variant, 0.0) + value
95
+ end
96
+
97
+ def drain_map(store)
98
+ out = []
99
+ store.each do |(metric, feature), variants|
100
+ variant_values = variants.reject { |_n, v| v.zero? }
101
+ next if variant_values.empty?
102
+
103
+ item = { metric: metric, variantValues: variant_values }
104
+ item[:feature] = feature unless feature.empty?
105
+ out << item
106
+ end
107
+ store.clear
108
+ out
109
+ end
110
+
111
+ def drain_observations
112
+ observation_messages = []
113
+ groups = {}
114
+ @observations.each do |obs|
115
+ when_t = obs[:time]
116
+ group_key = "#{when_t.to_f}\0#{obs[:metric]}\0#{obs[:feature] || ""}"
117
+ group = groups[group_key]
118
+ if group.nil? || group[:variantValues].key?(obs[:variant])
119
+ group = new_observation_group(when_t, obs)
120
+ groups[group_key] = group
121
+ observation_messages << group
122
+ end
123
+ group[:variantValues][obs[:variant]] = obs[:value]
124
+ end
125
+ @observations = []
126
+ observation_messages
127
+ end
128
+
129
+ def new_observation_group(when_t, obs)
130
+ group = {
131
+ time: GrpcClients.to_protobuf_timestamp(when_t),
132
+ metric: obs[:metric],
133
+ variantValues: {}
134
+ }
135
+ group[:feature] = obs[:feature] if obs[:feature] && !obs[:feature].to_s.empty?
136
+ group
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: metrics.proto
4
+ #
5
+ # Nested under Toggly::Telemetry::Pb to avoid top-level namespace pollution.
6
+ # Loaded only when optional grpc/google-protobuf gems are present.
7
+
8
+ require "google/protobuf"
9
+ require "google/protobuf/timestamp_pb"
10
+
11
+ descriptor_data = "\n\rmetrics.proto\x12\x07Metrics\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9c\x02\n\nMetricStat\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\x1a.Metrics.MetricStatMessage\x12/\n\x08\x63ounters\x18\x05 \x03(\x0b\x32\x1d.Metrics.MetricCounterMessage\x12\x37\n\x0cobservations\x18\x06 \x03(\x0b\x32!.Metrics.MetricObservationMessage\x12\x19\n\x0cinstanceName\x18\x07 \x01(\tH\x00\x88\x01\x01\x42\x0f\n\r_instanceName\"\xbb\x02\n\x11MetricStatMessage\x12\x0e\n\x06metric\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\x14\n\x07\x66\x65\x61ture\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x05value\x18\x05 \x01(\x01\x42\x02\x18\x01\x12\x1e\n\rvalueDisabled\x18\x06 \x01(\x01\x42\x02\x18\x01H\x01\x88\x01\x01\x12\x44\n\rvariantValues\x18\x07 \x03(\x0b\x32-.Metrics.MetricStatMessage.VariantValuesEntry\x1a\x34\n\x12VariantValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x01:\x02\x38\x01\x42\n\n\x08_featureB\x10\n\x0e_valueDisabled\"\xc1\x02\n\x14MetricCounterMessage\x12\x0e\n\x06metric\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\x14\n\x07\x66\x65\x61ture\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x05value\x18\x05 \x01(\x01\x42\x02\x18\x01\x12\x1e\n\rvalueDisabled\x18\x06 \x01(\x01\x42\x02\x18\x01H\x01\x88\x01\x01\x12G\n\rvariantValues\x18\x07 \x03(\x0b\x32\x30.Metrics.MetricCounterMessage.VariantValuesEntry\x1a\x34\n\x12VariantValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x01:\x02\x38\x01\x42\n\n\x08_featureB\x10\n\x0e_valueDisabled\"\xf3\x02\n\x18MetricObservationMessage\x12(\n\x04time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06metric\x18\x02 \x01(\t\x12\x18\n\x0c\x65nabledCount\x18\x03 \x01(\x05\x42\x02\x18\x01\x12\x19\n\rdisabledCount\x18\x04 \x01(\x05\x42\x02\x18\x01\x12\x14\n\x07\x66\x65\x61ture\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x05value\x18\x06 \x01(\x01\x42\x02\x18\x01\x12\x1e\n\rvalueDisabled\x18\x07 \x01(\x01\x42\x02\x18\x01H\x01\x88\x01\x01\x12K\n\rvariantValues\x18\x08 \x03(\x0b\x32\x34.Metrics.MetricObservationMessage.VariantValuesEntry\x1a\x34\n\x12VariantValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x01:\x02\x38\x01\x42\n\n\x08_featureB\x10\n\x0e_valueDisabled\"\x1d\n\x0cMetricResult\x12\r\n\x05\x63ount\x18\x01 \x01(\x05\x32\x44\n\x07Metrics\x12\x39\n\x0bSendMetrics\x12\x13.Metrics.MetricStat\x1a\x15.Metrics.MetricResultBOZMgithub.com/ops-ai/Toggly.FeatureManagement/toggly-go/toggly/metrics/metricspbb\x06proto3"
12
+
13
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
14
+ pool.add_serialized_file(descriptor_data)
15
+
16
+ module Toggly
17
+ module Telemetry
18
+ module Pb
19
+ module Metrics
20
+ MetricStat = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Metrics.MetricStat").msgclass
21
+ MetricStatMessage = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Metrics.MetricStatMessage").msgclass
22
+ MetricCounterMessage = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Metrics.MetricCounterMessage").msgclass
23
+ MetricObservationMessage = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Metrics.MetricObservationMessage").msgclass
24
+ MetricResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Metrics.MetricResult").msgclass
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # Source: metrics.proto for package 'Metrics'
4
+
5
+ require "grpc"
6
+ require_relative "metrics_pb"
7
+
8
+ module Toggly
9
+ module Telemetry
10
+ module Pb
11
+ module Metrics
12
+ module Metrics
13
+ class Service
14
+ include ::GRPC::GenericService
15
+
16
+ self.marshal_class_method = :encode
17
+ self.unmarshal_class_method = :decode
18
+ self.service_name = "Metrics.Metrics"
19
+
20
+ rpc :SendMetrics, ::Toggly::Telemetry::Pb::Metrics::MetricStat, ::Toggly::Telemetry::Pb::Metrics::MetricResult
21
+ end
22
+
23
+ Stub = Service.rpc_stub_class
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end