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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -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/definition_cache.rb +78 -0
- data/lib/toggly/definitions_provider.rb +72 -18
- 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/version.rb +1 -1
- data/lib/toggly.rb +2 -0
- data/proto/metrics.proto +60 -0
- data/proto/usage.proto +55 -0
- metadata +18 -3
|
@@ -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 [
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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: #{
|
|
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: #{
|
|
183
|
-
status_code:
|
|
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
|
|
190
|
-
|
|
191
|
-
@
|
|
192
|
-
|
|
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
|
-
|
|
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
|