smplkit 1.0.5

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE +21 -0
  4. data/README.md +105 -0
  5. data/lib/smplkit/client.rb +218 -0
  6. data/lib/smplkit/config/client.rb +238 -0
  7. data/lib/smplkit/config/helpers.rb +108 -0
  8. data/lib/smplkit/config/models.rb +192 -0
  9. data/lib/smplkit/config_resolution.rb +202 -0
  10. data/lib/smplkit/context.rb +68 -0
  11. data/lib/smplkit/debug.rb +50 -0
  12. data/lib/smplkit/errors.rb +114 -0
  13. data/lib/smplkit/flags/client.rb +480 -0
  14. data/lib/smplkit/flags/helpers.rb +76 -0
  15. data/lib/smplkit/flags/models.rb +258 -0
  16. data/lib/smplkit/flags/types.rb +233 -0
  17. data/lib/smplkit/generators/install_generator.rb +42 -0
  18. data/lib/smplkit/helpers.rb +15 -0
  19. data/lib/smplkit/log_level.rb +57 -0
  20. data/lib/smplkit/logging/adapters/base.rb +63 -0
  21. data/lib/smplkit/logging/adapters/semantic_logger_adapter.rb +88 -0
  22. data/lib/smplkit/logging/adapters/stdlib_logger_adapter.rb +143 -0
  23. data/lib/smplkit/logging/client.rb +142 -0
  24. data/lib/smplkit/logging/helpers.rb +69 -0
  25. data/lib/smplkit/logging/levels.rb +86 -0
  26. data/lib/smplkit/logging/models.rb +124 -0
  27. data/lib/smplkit/logging/normalize.rb +16 -0
  28. data/lib/smplkit/logging/sources.rb +44 -0
  29. data/lib/smplkit/management/buffer.rb +111 -0
  30. data/lib/smplkit/management/client.rb +623 -0
  31. data/lib/smplkit/management/models.rb +133 -0
  32. data/lib/smplkit/management/types.rb +65 -0
  33. data/lib/smplkit/metrics.rb +78 -0
  34. data/lib/smplkit/railtie.rb +48 -0
  35. data/lib/smplkit/version.rb +5 -0
  36. data/lib/smplkit/ws.rb +92 -0
  37. data/lib/smplkit.rb +43 -0
  38. data/sig/smplkit.rbs +141 -0
  39. metadata +139 -0
@@ -0,0 +1,480 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "json"
5
+ require "digest"
6
+
7
+ module Smplkit
8
+ module Flags
9
+ # Describes a flag definition change. Frozen — fields are set at construction.
10
+ class FlagChangeEvent
11
+ attr_reader :id, :source, :deleted
12
+
13
+ def initialize(id:, source:, deleted: false)
14
+ @id = id
15
+ @source = source
16
+ @deleted = deleted
17
+ freeze
18
+ end
19
+
20
+ def deleted? = @deleted
21
+
22
+ def ==(other)
23
+ other.is_a?(FlagChangeEvent) && id == other.id && source == other.source && deleted == other.deleted
24
+ end
25
+ alias eql? ==
26
+
27
+ def hash = [id, source, deleted].hash
28
+ end
29
+
30
+ # Thread-safe LRU resolution cache with hit/miss stats.
31
+ class ResolutionCache
32
+ DEFAULT_MAX_SIZE = 10_000
33
+
34
+ attr_reader :cache_hits, :cache_misses
35
+
36
+ def initialize(max_size: DEFAULT_MAX_SIZE)
37
+ @max_size = max_size
38
+ @cache = {}
39
+ @lock = Mutex.new
40
+ @cache_hits = 0
41
+ @cache_misses = 0
42
+ end
43
+
44
+ def get(cache_key)
45
+ @lock.synchronize do
46
+ if @cache.key?(cache_key)
47
+ value = @cache.delete(cache_key)
48
+ @cache[cache_key] = value
49
+ @cache_hits += 1
50
+ [true, value]
51
+ else
52
+ @cache_misses += 1
53
+ [false, nil]
54
+ end
55
+ end
56
+ end
57
+
58
+ def put(cache_key, value)
59
+ @lock.synchronize do
60
+ @cache.delete(cache_key) if @cache.key?(cache_key)
61
+ @cache[cache_key] = value
62
+ @cache.shift while @cache.size > @max_size
63
+ end
64
+ end
65
+
66
+ def clear
67
+ @lock.synchronize { @cache.clear }
68
+ end
69
+ end
70
+
71
+ # Evaluation statistics for the flags runtime.
72
+ FlagStats = Struct.new(:cache_hits, :cache_misses, keyword_init: true)
73
+
74
+ # Synchronous flags runtime namespace.
75
+ #
76
+ # Obtained via +Smplkit::Client#flags+. Exposes typed handles
77
+ # (+boolean_flag+/+string_flag+/+number_flag+/+json_flag+) and runtime
78
+ # control (+refresh+, +stats+, +on_change+). CRUD has moved to
79
+ # +mgmt.flags.*+. Per-request context is set via
80
+ # +client.set_context([...])+.
81
+ class FlagsClient
82
+ def initialize(parent, manage:, metrics:, flags_base_url:, app_base_url:)
83
+ @parent = parent
84
+ @manage = manage
85
+ @metrics = metrics
86
+ @service = parent._service
87
+ @environment = parent._environment
88
+ @flags_base_url = flags_base_url
89
+ @app_base_url = app_base_url
90
+
91
+ @flag_store = {}
92
+ @connected = false
93
+ @cache = ResolutionCache.new
94
+ @handles = {}
95
+ @global_listeners = []
96
+ @key_listeners = Hash.new { |h, k| h[k] = [] }
97
+ @ws_manager = nil
98
+ @lock = Mutex.new
99
+ end
100
+
101
+ def boolean_flag(id, default:)
102
+ register_handle(BooleanFlag, id, "BOOLEAN", default)
103
+ end
104
+
105
+ def string_flag(id, default:)
106
+ register_handle(StringFlag, id, "STRING", default)
107
+ end
108
+
109
+ def number_flag(id, default:)
110
+ register_handle(NumberFlag, id, "NUMERIC", default)
111
+ end
112
+
113
+ def json_flag(id, default:)
114
+ register_handle(JsonFlag, id, "JSON", default)
115
+ end
116
+
117
+ # Eagerly initialize the flags subclient.
118
+ #
119
+ # Drains any pending flag-declaration buffer, fetches all flag
120
+ # definitions, opens the shared WebSocket and subscribes to
121
+ # +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
122
+ #
123
+ # Idempotent — safe to call multiple times. Called automatically on
124
+ # first +flag.get+ evaluation if not invoked manually.
125
+ def start
126
+ return if @connected
127
+
128
+ @environment = @parent._environment
129
+ flush_flags_safely
130
+ refresh
131
+ @connected = true
132
+
133
+ @ws_manager = @parent._ensure_ws
134
+ @ws_manager.on("flag_changed") { |data| handle_flag_changed(data) }
135
+ @ws_manager.on("flag_deleted") { |data| handle_flag_deleted(data) }
136
+ @ws_manager.on("flags_changed") { |data| handle_flags_changed(data) }
137
+ end
138
+
139
+ def refresh
140
+ fetch_all_flags
141
+ @cache.clear
142
+ fire_change_listeners_all("manual")
143
+ end
144
+
145
+ def stats
146
+ FlagStats.new(cache_hits: @cache.cache_hits, cache_misses: @cache.cache_misses)
147
+ end
148
+
149
+ # Register a change listener.
150
+ #
151
+ # client.flags.on_change { |event| ... } # global
152
+ # client.flags.on_change("checkout-v2") { |e| ... } # flag-scoped
153
+ def on_change(flag_id = nil, &block)
154
+ raise ArgumentError, "on_change requires a block" unless block
155
+
156
+ if flag_id.nil?
157
+ @global_listeners << block
158
+ else
159
+ @key_listeners[flag_id] << block
160
+ end
161
+ block
162
+ end
163
+
164
+ def _close
165
+ # No durable resources here — kept for symmetry with Python SDK.
166
+ end
167
+
168
+ def _evaluate_handle(flag_id, default, context)
169
+ start unless @connected
170
+
171
+ eval_dict =
172
+ if context
173
+ @manage.contexts.register(context) if @manage.respond_to?(:contexts)
174
+ contexts_to_eval_dict(context)
175
+ else
176
+ current = Smplkit.request_context
177
+ current.empty? ? {} : contexts_to_eval_dict(current)
178
+ end
179
+
180
+ eval_dict["service"] = { "key" => @service } if @service && !eval_dict.key?("service")
181
+
182
+ ctx_hash = hash_context(eval_dict)
183
+ cache_key = "#{flag_id}:#{ctx_hash}"
184
+
185
+ hit, cached_value = @cache.get(cache_key)
186
+ if hit
187
+ @metrics&.record("flags.cache_hits", unit: "hits")
188
+ @metrics&.record("flags.evaluations", unit: "evaluations", dimensions: { "flag" => flag_id })
189
+ return cached_value
190
+ end
191
+
192
+ flag_def = @flag_store[flag_id]
193
+ if flag_def.nil?
194
+ @cache.put(cache_key, default)
195
+ return default
196
+ end
197
+
198
+ value = evaluate_flag(flag_def, @environment, eval_dict)
199
+ value = default if value.nil?
200
+ @cache.put(cache_key, value)
201
+ @metrics&.record("flags.cache_misses", unit: "misses")
202
+ @metrics&.record("flags.evaluations", unit: "evaluations", dimensions: { "flag" => flag_id })
203
+ value
204
+ end
205
+
206
+ private
207
+
208
+ def register_handle(klass, id, type_name, default)
209
+ handle = klass.new(self, id: id, name: id, type: type_name, default: default)
210
+ @handles[id] = handle
211
+ if @manage.respond_to?(:flags)
212
+ @manage.flags.register(FlagDeclaration.new(
213
+ id: id, type: type_name, default: default,
214
+ service: @service, environment: @environment
215
+ ))
216
+ end
217
+ handle
218
+ end
219
+
220
+ def flush_flags_safely
221
+ @manage.flags.flush
222
+ rescue StandardError => e
223
+ Smplkit.debug("registration", "bulk flag registration failed: #{e.class}: #{e.message}")
224
+ end
225
+
226
+ def fetch_all_flags
227
+ flags = @parent._flags_transport.list_flags
228
+ @flag_store = flags.to_h { |f| [f["id"], f] }
229
+ rescue Smplkit::Error
230
+ raise
231
+ rescue StandardError => e
232
+ raise Smplkit::ConnectionError, "Failed to fetch flags: #{e.message}"
233
+ end
234
+
235
+ def contexts_to_eval_dict(contexts)
236
+ contexts.to_h { |ctx| [ctx.type, ctx.to_eval_hash] }
237
+ end
238
+
239
+ def hash_context(eval_dict)
240
+ Digest::MD5.hexdigest(JSON.generate(deep_sort(eval_dict)))
241
+ end
242
+
243
+ def deep_sort(value)
244
+ case value
245
+ when Hash
246
+ value.keys.sort_by(&:to_s).to_h { |k| [k, deep_sort(value[k])] }
247
+ when Array
248
+ value.map { |v| deep_sort(v) }
249
+ else
250
+ value
251
+ end
252
+ end
253
+
254
+ def handle_flag_changed(data)
255
+ key = data["id"]
256
+ return unless key
257
+
258
+ pre = @flag_store[key]&.dup || {}
259
+ new_data = @parent._flags_transport.fetch_flag(key)
260
+ @flag_store[key] = new_data
261
+ @cache.clear
262
+ fire_change_listeners(key, "websocket") if pre != new_data
263
+ end
264
+
265
+ def handle_flag_deleted(data)
266
+ key = data["id"]
267
+ return unless key
268
+
269
+ existed = @flag_store.key?(key)
270
+ @flag_store.delete(key)
271
+ @cache.clear
272
+ fire_change_listeners(key, "websocket", deleted: true) if existed
273
+ end
274
+
275
+ def handle_flags_changed(_data)
276
+ pre_store = @flag_store.dup
277
+ begin
278
+ fetch_all_flags
279
+ rescue StandardError => e
280
+ Smplkit.debug("ws", "flags refresh after flags_changed failed: #{e.message}")
281
+ return
282
+ end
283
+ @cache.clear
284
+ post_store = @flag_store
285
+ all_keys = pre_store.keys | post_store.keys
286
+ changed = all_keys.reject { |k| pre_store[k] == post_store[k] }
287
+ return if changed.empty?
288
+
289
+ first_event = FlagChangeEvent.new(id: changed.first, source: "websocket")
290
+ @global_listeners.each do |cb|
291
+ cb.call(first_event)
292
+ rescue StandardError => e
293
+ Smplkit.debug("flags", "global listener raised: #{e.class}: #{e.message}")
294
+ end
295
+
296
+ changed.each do |k|
297
+ deleted = pre_store.key?(k) && !post_store.key?(k)
298
+ event = FlagChangeEvent.new(id: k, source: "websocket", deleted: deleted)
299
+ @key_listeners[k].each do |cb|
300
+ cb.call(event)
301
+ rescue StandardError => e
302
+ Smplkit.debug("flags", "scoped listener raised: #{e.class}: #{e.message}")
303
+ end
304
+ end
305
+ end
306
+
307
+ def fire_change_listeners(flag_id, source, deleted: false)
308
+ return unless flag_id
309
+
310
+ event = FlagChangeEvent.new(id: flag_id, source: source, deleted: deleted)
311
+ (@global_listeners + @key_listeners[flag_id]).each do |cb|
312
+ cb.call(event)
313
+ rescue StandardError => e
314
+ Smplkit.debug("flags", "listener raised: #{e.class}: #{e.message}")
315
+ end
316
+ end
317
+
318
+ def fire_change_listeners_all(source)
319
+ @flag_store.each_key { |id| fire_change_listeners(id, source) }
320
+ end
321
+
322
+ # Evaluate a flag definition against the given context.
323
+ #
324
+ # Follows ADR-022 §2.6 semantics:
325
+ # 1. Look up the environment. If missing, return flag-level default.
326
+ # 2. If disabled, return env default or flag default.
327
+ # 3. Iterate rules; first match wins.
328
+ # 4. No match -> env default or flag default.
329
+ def evaluate_flag(flag_def, environment, eval_dict)
330
+ flag_default = flag_def["default"]
331
+ environments = flag_def["environments"] || {}
332
+
333
+ return flag_default if environment.nil? || !environments.key?(environment)
334
+
335
+ env_config = environments[environment]
336
+ fallback = env_config.default.nil? ? flag_default : env_config.default
337
+ return fallback unless env_config.enabled
338
+
339
+ env_config.rules.each do |rule|
340
+ next if rule.logic.nil? || rule.logic.empty?
341
+
342
+ begin
343
+ result = JsonLogicEvaluator.apply(rule.logic, eval_dict)
344
+ return rule.value if result
345
+ rescue StandardError => e
346
+ Smplkit.debug("flags", "json logic evaluation error for rule: #{e.class}: #{e.message}")
347
+ next
348
+ end
349
+ end
350
+
351
+ fallback
352
+ end
353
+ end
354
+
355
+ # Vendored minimal JSON Logic evaluator covering the operators the smplkit
356
+ # platform ships in flag rules.
357
+ #
358
+ # Stays in-tree so the Ruby SDK doesn't depend on the +json_logic+ gem
359
+ # being correct — the Java SDK followed the same pattern. Operators
360
+ # supported: +==+, +!=+, +<+, +<=+, +>+, +>=+, +in+, +var+, +and+, +or+,
361
+ # +!+, +if+, +missing+, +none+.
362
+ module JsonLogicEvaluator
363
+ module_function
364
+
365
+ def apply(logic, data)
366
+ return logic unless logic.is_a?(Hash)
367
+ return logic if logic.empty?
368
+
369
+ op, values = logic.first
370
+ values = [values] unless values.is_a?(Array)
371
+
372
+ case op
373
+ when "var"
374
+ resolve_var(values[0], data, values[1])
375
+ when "and"
376
+ values.reduce(true) { |acc, v| acc && truthy?(apply(v, data)) }
377
+ when "or"
378
+ values.reduce(false) { |acc, v| acc || truthy?(apply(v, data)) }
379
+ when "!"
380
+ !truthy?(apply(values[0], data))
381
+ when "if"
382
+ eval_if(values, data)
383
+ when "==", "==="
384
+ apply(values[0], data) == apply(values[1], data)
385
+ when "!=", "!=="
386
+ apply(values[0], data) != apply(values[1], data)
387
+ when "<"
388
+ compare(values, data) { |a, b| a < b }
389
+ when "<="
390
+ compare(values, data) { |a, b| a <= b }
391
+ when ">"
392
+ compare(values, data) { |a, b| a > b }
393
+ when ">="
394
+ compare(values, data) { |a, b| a >= b }
395
+ when "in"
396
+ eval_in(values, data)
397
+ when "missing"
398
+ eval_missing(values, data)
399
+ when "none"
400
+ values_arr = apply(values[0], data) || []
401
+ inner = values[1]
402
+ values_arr.is_a?(Array) && values_arr.none? { |item| truthy?(apply(inner, item)) }
403
+ else
404
+ false
405
+ end
406
+ end
407
+
408
+ def truthy?(value)
409
+ return false if value.nil?
410
+ return false if value == false
411
+ return false if value.is_a?(Numeric) && value.zero?
412
+ return false if value == ""
413
+ return false if value == []
414
+
415
+ true
416
+ end
417
+
418
+ def resolve_var(path, data, default = nil)
419
+ return data if path.nil? || path == "" || path == []
420
+
421
+ keys = path.is_a?(Array) ? path : path.to_s.split(".")
422
+ keys.reduce(data) do |scope, key|
423
+ break default if scope.nil?
424
+
425
+ if scope.is_a?(Hash)
426
+ scope[key] || scope[key.to_s] || scope[key.to_sym]
427
+ elsif scope.is_a?(Array) && key.to_s =~ /\A\d+\z/
428
+ scope[key.to_i]
429
+ else
430
+ default
431
+ end
432
+ end || default
433
+ end
434
+
435
+ def compare(values, data)
436
+ applied = values.map { |v| apply(v, data) }
437
+ return false if applied.any?(&:nil?)
438
+
439
+ if applied.length == 2
440
+ yield(applied[0], applied[1])
441
+ elsif applied.length == 3
442
+ yield(applied[0], applied[1]) && yield(applied[1], applied[2])
443
+ else
444
+ false
445
+ end
446
+ rescue ArgumentError, TypeError
447
+ false
448
+ end
449
+
450
+ def eval_if(values, data)
451
+ i = 0
452
+ while i + 1 < values.length
453
+ return apply(values[i + 1], data) if truthy?(apply(values[i], data))
454
+
455
+ i += 2
456
+ end
457
+ i < values.length ? apply(values[i], data) : nil
458
+ end
459
+
460
+ def eval_in(values, data)
461
+ needle = apply(values[0], data)
462
+ haystack = apply(values[1], data)
463
+ return false if haystack.nil?
464
+
465
+ haystack.include?(needle)
466
+ rescue NoMethodError, TypeError
467
+ false
468
+ end
469
+
470
+ def eval_missing(values, data)
471
+ keys = values.is_a?(Array) ? values.flatten : [values]
472
+ keys.reject { |k| present?(resolve_var(k, data)) }
473
+ end
474
+
475
+ def present?(value)
476
+ !(value.nil? || value == "")
477
+ end
478
+ end
479
+ end
480
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smplkit
4
+ module Flags
5
+ # Helpers that translate between server JSON and the SDK's wrapper model
6
+ # objects.
7
+ module Helpers
8
+ module_function
9
+
10
+ # Translate a JSON:API resource Hash into a flat dict the runtime cache
11
+ # can consume. The shape mirrors +smplkit.flags.helpers._flag_dict_from_json+
12
+ # in the Python SDK.
13
+ def flag_dict_from_json(resource)
14
+ attrs = resource["attributes"] || {}
15
+ environments = (attrs["environments"] || {}).each_with_object({}) do |(env_key, env_data), out|
16
+ out[env_key] = parse_env(env_data || {})
17
+ end
18
+
19
+ {
20
+ "id" => resource["id"] || attrs["id"],
21
+ "name" => attrs["name"],
22
+ "type" => attrs["type"],
23
+ "default" => attrs["default"],
24
+ "values" => parse_values(attrs["values"]),
25
+ "description" => attrs["description"],
26
+ "environments" => environments
27
+ }
28
+ end
29
+
30
+ def parse_values(raw)
31
+ return nil if raw.nil?
32
+
33
+ raw.map { |v| FlagValue.new(name: v["name"], value: v["value"]) }
34
+ end
35
+
36
+ def parse_env(env_data)
37
+ rules = (env_data["rules"] || []).map do |r|
38
+ FlagRule.new(
39
+ logic: r["logic"] || {},
40
+ value: r["value"],
41
+ description: r["description"]
42
+ )
43
+ end
44
+ FlagEnvironment.new(
45
+ enabled: env_data.fetch("enabled", true) ? true : false,
46
+ default: env_data["default"],
47
+ rules: rules
48
+ )
49
+ end
50
+
51
+ # Build the JSON body for a Flag create/update request.
52
+ def build_flag_request_body(flag)
53
+ environments = flag.environments.each_with_object({}) do |(env_key, env), out|
54
+ out[env_key] = {
55
+ "enabled" => env.enabled,
56
+ "default" => env.default,
57
+ "rules" => env.rules.map { |r| { "logic" => r.logic, "value" => r.value, "description" => r.description } }
58
+ }
59
+ end
60
+
61
+ attributes = {
62
+ "id" => flag.id,
63
+ "name" => flag.name,
64
+ "type" => flag.type,
65
+ "default" => flag.default,
66
+ "description" => flag.description,
67
+ "environments" => environments
68
+ }
69
+ values = flag.values
70
+ attributes["values"] = values.map { |v| { "name" => v.name, "value" => v.value } } if values
71
+
72
+ { "data" => { "type" => "flag", "id" => flag.id, "attributes" => attributes.compact } }
73
+ end
74
+ end
75
+ end
76
+ end