toggly 0.3.0 → 0.5.1

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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/toggly.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "toggly/version"
4
+ require_relative "toggly/telemetry"
4
5
  require_relative "toggly/config"
5
6
  require_relative "toggly/request_context"
6
7
  require_relative "toggly/http_request_mapper"
@@ -30,6 +31,7 @@ require_relative "toggly/evaluation_engine"
30
31
  require_relative "toggly/snapshot_providers/base"
31
32
  require_relative "toggly/snapshot_providers/memory"
32
33
  require_relative "toggly/snapshot_providers/file"
34
+ require_relative "toggly/definition_cache"
33
35
  require_relative "toggly/definitions_provider"
34
36
  require_relative "toggly/client"
35
37
 
@@ -77,6 +79,7 @@ module Toggly
77
79
  def configure
78
80
  config = Config.new
79
81
  yield(config) if block_given?
82
+ config.apply_telemetry_defaults!
80
83
  @client = Client.new(config)
81
84
  end
82
85
 
@@ -0,0 +1,60 @@
1
+ syntax = "proto3";
2
+
3
+ package Metrics;
4
+
5
+ option go_package = "github.com/ops-ai/Toggly.FeatureManagement/toggly-go/toggly/metrics/metricspb";
6
+
7
+ import "google/protobuf/timestamp.proto";
8
+
9
+ message MetricStat {
10
+ string appKey = 1;
11
+ string environment = 2;
12
+ google.protobuf.Timestamp time = 3;
13
+ repeated MetricStatMessage stats = 4;
14
+ repeated MetricCounterMessage counters = 5;
15
+ repeated MetricObservationMessage observations = 6;
16
+ optional string instanceName = 7;
17
+ }
18
+
19
+ message MetricStatMessage {
20
+ string metric = 1;
21
+ int32 enabledCount = 2 [deprecated = true];
22
+ int32 disabledCount = 3 [deprecated = true];
23
+ optional string feature = 4;
24
+ double value = 5 [deprecated = true];
25
+ optional double valueDisabled = 6 [deprecated = true];
26
+
27
+ // Multi-variate support - Key: variant name (e.g., "enabled", "control")
28
+ map<string, double> variantValues = 7;
29
+ }
30
+
31
+ message MetricCounterMessage {
32
+ string metric = 1;
33
+ int32 enabledCount = 2 [deprecated = true];
34
+ int32 disabledCount = 3 [deprecated = true];
35
+ optional string feature = 4;
36
+ double value = 5 [deprecated = true];
37
+ optional double valueDisabled = 6 [deprecated = true];
38
+
39
+ map<string, double> variantValues = 7;
40
+ }
41
+
42
+ message MetricObservationMessage {
43
+ google.protobuf.Timestamp time = 1;
44
+ string metric = 2;
45
+ int32 enabledCount = 3 [deprecated = true];
46
+ int32 disabledCount = 4 [deprecated = true];
47
+ optional string feature = 5;
48
+ double value = 6 [deprecated = true];
49
+ optional double valueDisabled = 7 [deprecated = true];
50
+
51
+ map<string, double> variantValues = 8;
52
+ }
53
+
54
+ message MetricResult {
55
+ int32 count = 1;
56
+ }
57
+
58
+ service Metrics {
59
+ rpc SendMetrics(MetricStat) returns (MetricResult);
60
+ }
data/proto/usage.proto ADDED
@@ -0,0 +1,55 @@
1
+ syntax = "proto3";
2
+
3
+ package Usage;
4
+
5
+ option go_package = "github.com/ops-ai/Toggly.FeatureManagement/toggly-go/toggly/usage/usagepb";
6
+
7
+ import "google/protobuf/timestamp.proto";
8
+
9
+ message FeatureStat {
10
+ string appKey = 1;
11
+ string environment = 2;
12
+ google.protobuf.Timestamp time = 3;
13
+ repeated StatMessage stats = 4;
14
+ int32 totalUniqueUsers = 5;
15
+ optional string instanceName = 6;
16
+ optional google.protobuf.Timestamp processStartTime = 7;
17
+ optional string appVersion = 8;
18
+ repeated int32 uniqueUserHashes = 9; // Application-level unique user ID hashes
19
+ optional int32 definitionCacheHits = 10; // SDK-reported definition-refresh cache hits (batch delta)
20
+ optional int32 definitionCacheMisses = 11; // SDK-reported definition-refresh cache misses (batch delta)
21
+ }
22
+
23
+ message StatMessage {
24
+ string feature = 1;
25
+ int32 enabledCount = 2 [deprecated = true]; // Legacy: use variantStats instead
26
+ int32 disabledCount = 3 [deprecated = true]; // Legacy: use variantStats instead
27
+ int32 uniqueContextIdentifierEnabledCount = 4;
28
+ int32 uniqueContextIdentifierDisabledCount = 5;
29
+ int32 uniqueRequestEnabledCount = 6 [deprecated = true]; // Legacy: use variantStats instead
30
+ int32 uniqueRequestDisabledCount = 7 [deprecated = true]; // Legacy: use variantStats instead
31
+ int32 usedCount = 8 [deprecated = true]; // Legacy: use variantStats instead
32
+ int32 uniqueUsersUsedCount = 9;
33
+ repeated int32 uniqueUserHashes = 10; // Unique user ID hashes for "used" tracking
34
+
35
+ // Multi-variate support - all new metrics should use this
36
+ map<string, VariantStats> variantStats = 11; // Key: variant name (e.g., "enabled", "disabled")
37
+
38
+ // Unique user tracking for "viewed" metric (per feature, not per variant)
39
+ repeated int32 uniqueViewedUserHashes = 12;
40
+ }
41
+
42
+ message VariantStats {
43
+ int32 checkCount = 1;
44
+ int32 requestCount = 2;
45
+ int32 usedCount = 3;
46
+ int32 viewedCount = 4;
47
+ }
48
+
49
+ message StatResult {
50
+ int32 featureCount = 1;
51
+ }
52
+
53
+ service Usage {
54
+ rpc SendStats(FeatureStat) returns (StatResult);
55
+ }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toggly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-04 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: High-performance Ruby SDK for Toggly feature flag management. Works with
14
14
  or without Toggly.io.
@@ -23,8 +23,11 @@ files:
23
23
  - README.md
24
24
  - lib/toggly.rb
25
25
  - lib/toggly/client.rb
26
+ - lib/toggly/client/cache_telemetry.rb
27
+ - lib/toggly/client/snapshot_support.rb
26
28
  - lib/toggly/config.rb
27
29
  - lib/toggly/context.rb
30
+ - lib/toggly/definition_cache.rb
28
31
  - lib/toggly/definitions_provider.rb
29
32
  - lib/toggly/entity_context.rb
30
33
  - lib/toggly/errors.rb
@@ -52,8 +55,20 @@ files:
52
55
  - lib/toggly/snapshot_providers/file.rb
53
56
  - lib/toggly/snapshot_providers/memory.rb
54
57
  - lib/toggly/sticky_hash.rb
58
+ - lib/toggly/telemetry.rb
59
+ - lib/toggly/telemetry/grpc_clients.rb
60
+ - lib/toggly/telemetry/metrics_batcher.rb
61
+ - lib/toggly/telemetry/pb/metrics_pb.rb
62
+ - lib/toggly/telemetry/pb/metrics_services_pb.rb
63
+ - lib/toggly/telemetry/pb/usage_pb.rb
64
+ - lib/toggly/telemetry/pb/usage_services_pb.rb
65
+ - lib/toggly/telemetry/runtime.rb
66
+ - lib/toggly/telemetry/runtime_flush.rb
67
+ - lib/toggly/telemetry/usage_batcher.rb
55
68
  - lib/toggly/user_agent_parser.rb
56
69
  - lib/toggly/version.rb
70
+ - proto/metrics.proto
71
+ - proto/usage.proto
57
72
  homepage: https://toggly.io
58
73
  licenses:
59
74
  - MIT
@@ -63,7 +78,7 @@ metadata:
63
78
  changelog_uri: https://github.com/ops-ai/toggly-ruby/blob/main/CHANGELOG.md
64
79
  documentation_uri: https://docs.toggly.io/sdks/ruby
65
80
  rubygems_mfa_required: 'true'
66
- optional_dependencies: websocket-client-simple
81
+ optional_dependencies: websocket-client-simple, grpc, google-protobuf
67
82
  post_install_message:
68
83
  rdoc_options: []
69
84
  require_paths: