featurevisor-openfeature 1.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ac21a86ef0432191f6a12f262dd00b524f520a0e5a1cd88bf2029b0428842c46
4
+ data.tar.gz: 9add287e8f085ee1b446db5ccf5c577a2f4758a78be61ca36dc0af4793b9f8ab
5
+ SHA512:
6
+ metadata.gz: f0ea87c62a2cfbe51bd00c5d3d75b2fb1f26f2989d5df8cfe44f52fba840374673c439960855441783074c61e531185d11decd393838cc7da485740467e53e3e
7
+ data.tar.gz: d93238543cd2e2bef46ed2d2bc6c56ce4ca6bf8fc35211a837f1523d1e14c7bcbb016a7aea95d2b1057e2d09632d949c928d098495d0315a38fc868fdb640ae4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Fahad Heylaal (https://fahad19.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,912 @@
1
+ # Featurevisor Ruby SDK <!-- omit in toc -->
2
+
3
+ This is a port of Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Ruby, providing a way to evaluate feature flags, variations, and variables in your Ruby applications.
4
+
5
+ This SDK is compatible with Featurevisor v3 projects and v2 datafiles.
6
+
7
+ ## Table of contents <!-- omit in toc -->
8
+
9
+ - [Installation](#installation)
10
+ - [Public API](#public-api)
11
+ - [Initialization](#initialization)
12
+ - [Evaluation types](#evaluation-types)
13
+ - [Context](#context)
14
+ - [Setting initial context](#setting-initial-context)
15
+ - [Setting after initialization](#setting-after-initialization)
16
+ - [Replacing existing context](#replacing-existing-context)
17
+ - [Manually passing context](#manually-passing-context)
18
+ - [Check if enabled](#check-if-enabled)
19
+ - [Getting variation](#getting-variation)
20
+ - [Getting variables](#getting-variables)
21
+ - [Type specific methods](#type-specific-methods)
22
+ - [Getting all evaluations](#getting-all-evaluations)
23
+ - [Sticky](#sticky)
24
+ - [Initialize with sticky](#initialize-with-sticky)
25
+ - [Set sticky afterwards](#set-sticky-afterwards)
26
+ - [Setting datafile](#setting-datafile)
27
+ - [Merging by default](#merging-by-default)
28
+ - [Replacing](#replacing)
29
+ - [Loading datafiles on demand](#loading-datafiles-on-demand)
30
+ - [Updating datafile](#updating-datafile)
31
+ - [Interval-based update](#interval-based-update)
32
+ - [Evaluation details](#evaluation-details)
33
+ - [Diagnostics](#diagnostics)
34
+ - [Levels](#levels)
35
+ - [Handler](#handler)
36
+ - [Events](#events)
37
+ - [`datafile_set`](#datafile_set)
38
+ - [`context_set`](#context_set)
39
+ - [`sticky_set`](#sticky_set)
40
+ - [`error`](#error)
41
+ - [Modules](#modules)
42
+ - [Defining a module](#defining-a-module)
43
+ - [Registering modules](#registering-modules)
44
+ - [Child instance](#child-instance)
45
+ - [Close](#close)
46
+ - [OpenFeature](#openfeature)
47
+ - [CLI usage](#cli-usage)
48
+ - [Test](#test)
49
+ - [Test against local monorepo's example-1](#test-against-local-monorepos-example-1)
50
+ - [Benchmark](#benchmark)
51
+ - [Assess distribution](#assess-distribution)
52
+ - [Development](#development)
53
+ - [Setting up](#setting-up)
54
+ - [Running tests](#running-tests)
55
+ - [Releasing](#releasing)
56
+ - [License](#license)
57
+
58
+ <!-- FEATUREVISOR_DOCS_BEGIN -->
59
+
60
+ ## Installation
61
+
62
+ Add this line to your application's Gemfile:
63
+
64
+ ```ruby
65
+ gem 'featurevisor'
66
+ ```
67
+
68
+ And then execute:
69
+
70
+ ```bash
71
+ $ bundle install
72
+ ```
73
+
74
+ Or install it yourself as:
75
+
76
+ ```bash
77
+ $ gem install featurevisor
78
+ ```
79
+
80
+ ## Public API
81
+
82
+ The main runtime API is `Featurevisor.create_featurevisor`:
83
+
84
+ ```ruby
85
+ f = Featurevisor.create_featurevisor(
86
+ datafile: datafile_content
87
+ )
88
+ ```
89
+
90
+ Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory.
91
+
92
+ ## Initialization
93
+
94
+ The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly:
95
+
96
+ ```ruby
97
+ require 'featurevisor'
98
+ require 'net/http'
99
+ require 'json'
100
+
101
+ # Fetch datafile from URL
102
+ datafile_url = 'https://cdn.yoursite.com/datafile.json'
103
+ response = Net::HTTP.get_response(URI(datafile_url))
104
+
105
+ # Parse JSON with symbolized keys (required)
106
+ datafile_content = JSON.parse(response.body, symbolize_names: true)
107
+
108
+ # Create SDK instance
109
+ f = Featurevisor.create_featurevisor(
110
+ datafile: datafile_content
111
+ )
112
+ ```
113
+
114
+ **Important**: When parsing JSON datafiles, you must use `symbolize_names: true` to ensure proper key handling by the SDK.
115
+
116
+ Alternatively, you can pass a JSON string directly and the SDK will parse it automatically:
117
+
118
+ ```ruby
119
+ # Option 1: Parse JSON yourself (recommended)
120
+ datafile_content = JSON.parse(json_string, symbolize_names: true)
121
+ f = Featurevisor.create_featurevisor(datafile: datafile_content)
122
+
123
+ # Option 2: Pass JSON string directly (automatic parsing)
124
+ f = Featurevisor.create_featurevisor(datafile: json_string)
125
+ ```
126
+
127
+ ## Evaluation types
128
+
129
+ We can evaluate 3 types of values against a particular [feature](https://featurevisor.com/docs/features/):
130
+
131
+ - [**Flag**](#check-if-enabled) (`boolean`): whether the feature is enabled or not
132
+ - [**Variation**](#getting-variation) (`string`): the variation of the feature (if any)
133
+ - [**Variables**](#getting-variables): variable values of the feature (if any)
134
+
135
+ These evaluations are run against the provided context.
136
+
137
+ ## Context
138
+
139
+ Contexts are [attribute](https://featurevisor.com/docs/attributes/) values that we pass to SDK for evaluating [features](https://featurevisor.com/docs/features/) against.
140
+
141
+ Think of the conditions that you define in your [segments](https://featurevisor.com/docs/segments/), which are used in your feature's [rules](https://featurevisor.com/docs/features/#rules).
142
+
143
+ They are plain hashes:
144
+
145
+ ```ruby
146
+ context = {
147
+ userId: '123',
148
+ country: 'nl',
149
+ # ...other attributes
150
+ }
151
+ ```
152
+
153
+ Context can be passed to SDK instance in various different ways, depending on your needs:
154
+
155
+ ### Setting initial context
156
+
157
+ You can set context at the time of initialization:
158
+
159
+ ```ruby
160
+ require 'featurevisor'
161
+
162
+ f = Featurevisor.create_featurevisor(
163
+ context: {
164
+ deviceId: '123',
165
+ country: 'nl'
166
+ }
167
+ )
168
+ ```
169
+
170
+ This is useful for values that don't change too frequently and available at the time of application startup.
171
+
172
+ ### Setting after initialization
173
+
174
+ You can also set more context after the SDK has been initialized:
175
+
176
+ ```ruby
177
+ f.set_context({
178
+ userId: '234'
179
+ })
180
+ ```
181
+
182
+ This will merge the new context with the existing one (if already set).
183
+
184
+ ### Replacing existing context
185
+
186
+ If you wish to fully replace the existing context, you can pass `true` in second argument:
187
+
188
+ ```ruby
189
+ f.set_context({
190
+ deviceId: '123',
191
+ userId: '234',
192
+ country: 'nl',
193
+ browser: 'chrome'
194
+ }, true) # replace existing context
195
+ ```
196
+
197
+ ### Manually passing context
198
+
199
+ You can optionally pass additional context manually for each and every evaluation separately, without needing to set it to the SDK instance affecting all evaluations:
200
+
201
+ ```ruby
202
+ context = {
203
+ userId: '123',
204
+ country: 'nl'
205
+ }
206
+
207
+ is_enabled = f.is_enabled('my_feature', context)
208
+ variation = f.get_variation('my_feature', context)
209
+ variable_value = f.get_variable('my_feature', 'my_variable', context)
210
+ ```
211
+
212
+ When manually passing context, it will merge with existing context set to the SDK instance before evaluating the specific value.
213
+
214
+ Further details for each evaluation types are described below.
215
+
216
+ ## Check if enabled
217
+
218
+ Once the SDK is initialized, you can check if a feature is enabled or not:
219
+
220
+ ```ruby
221
+ feature_key = 'my_feature'
222
+
223
+ is_enabled = f.is_enabled(feature_key)
224
+
225
+ if is_enabled
226
+ # do something
227
+ end
228
+ ```
229
+
230
+ You can also pass additional context per evaluation:
231
+
232
+ ```ruby
233
+ is_enabled = f.is_enabled(feature_key, {
234
+ # ...additional context
235
+ })
236
+ ```
237
+
238
+ ## Getting variation
239
+
240
+ If your feature has any [variations](https://featurevisor.com/docs/features/#variations) defined, you can evaluate them as follows:
241
+
242
+ ```ruby
243
+ feature_key = 'my_feature'
244
+
245
+ variation = f.get_variation(feature_key)
246
+
247
+ if variation == 'treatment'
248
+ # do something for treatment variation
249
+ else
250
+ # handle default/control variation
251
+ end
252
+ ```
253
+
254
+ Additional context per evaluation can also be passed:
255
+
256
+ ```ruby
257
+ variation = f.get_variation(feature_key, {
258
+ # ...additional context
259
+ })
260
+ ```
261
+
262
+ ## Getting variables
263
+
264
+ Your features may also include [variables](https://featurevisor.com/docs/features/#variables), which can be evaluated as follows:
265
+
266
+ ```ruby
267
+ variable_key = 'bgColor'
268
+
269
+ bg_color_value = f.get_variable('my_feature', variable_key)
270
+ ```
271
+
272
+ Additional context per evaluation can also be passed:
273
+
274
+ ```ruby
275
+ bg_color_value = f.get_variable('my_feature', variable_key, {
276
+ # ...additional context
277
+ })
278
+ ```
279
+
280
+ ### Type specific methods
281
+
282
+ Next to generic `get_variable()` methods, there are also type specific methods available for convenience:
283
+
284
+ ```ruby
285
+ f.get_variable_boolean(feature_key, variable_key, context = {})
286
+ f.get_variable_string(feature_key, variable_key, context = {})
287
+ f.get_variable_integer(feature_key, variable_key, context = {})
288
+ f.get_variable_double(feature_key, variable_key, context = {})
289
+ f.get_variable_array(feature_key, variable_key, context = {})
290
+ f.get_variable_object(feature_key, variable_key, context = {})
291
+ f.get_variable_json(feature_key, variable_key, context = {})
292
+ ```
293
+
294
+ Type specific methods do not coerce values. `get_variable_integer` returns `nil` for the string `"1"`, and boolean getters return `nil` for non-boolean values.
295
+
296
+ ## Getting all evaluations
297
+
298
+ You can get evaluations of all features available in the SDK instance:
299
+
300
+ ```ruby
301
+ all_evaluations = f.get_all_evaluations({})
302
+
303
+ puts all_evaluations
304
+ # {
305
+ # myFeature: {
306
+ # enabled: true,
307
+ # variation: "control",
308
+ # variables: {
309
+ # myVariableKey: "myVariableValue",
310
+ # },
311
+ # },
312
+ #
313
+ # anotherFeature: {
314
+ # enabled: true,
315
+ # variation: "treatment",
316
+ # }
317
+ # }
318
+ ```
319
+
320
+ This is handy especially when you want to pass all evaluations from a backend application to the frontend.
321
+
322
+ ## Sticky
323
+
324
+ For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/):
325
+
326
+ Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn(context, sticky: ...)` when a child needs its own sticky state.
327
+
328
+ ### Initialize with sticky
329
+
330
+ ```ruby
331
+ require 'featurevisor'
332
+
333
+ f = Featurevisor.create_featurevisor(
334
+ sticky: {
335
+ myFeatureKey: {
336
+ enabled: true,
337
+ # optional
338
+ variation: 'treatment',
339
+ variables: {
340
+ myVariableKey: 'myVariableValue'
341
+ }
342
+ },
343
+ anotherFeatureKey: {
344
+ enabled: false
345
+ }
346
+ }
347
+ )
348
+ ```
349
+
350
+ Once initialized with sticky features, the SDK will look for values there first before evaluating the targeting conditions and going through the bucketing process.
351
+
352
+ ### Set sticky afterwards
353
+
354
+ You can also set sticky features after the SDK is initialized:
355
+
356
+ ```ruby
357
+ f.set_sticky({
358
+ myFeatureKey: {
359
+ enabled: true,
360
+ variation: 'treatment',
361
+ variables: {
362
+ myVariableKey: 'myVariableValue'
363
+ }
364
+ },
365
+ anotherFeatureKey: {
366
+ enabled: false
367
+ }
368
+ }, true) # replace existing sticky features (false by default)
369
+ ```
370
+
371
+ ## Setting datafile
372
+
373
+ You may also initialize the SDK without passing `datafile`, and set it later on:
374
+
375
+ ```ruby
376
+ # Parse with symbolized keys before setting
377
+ datafile_content = JSON.parse(json_string, symbolize_names: true)
378
+ f.set_datafile(datafile_content)
379
+
380
+ # Or pass JSON string directly for automatic parsing
381
+ f.set_datafile(json_string)
382
+ ```
383
+
384
+ **Important**: When calling `set_datafile()`, ensure JSON is parsed with `symbolize_names: true` if you're parsing it yourself.
385
+
386
+ ### Merging by default
387
+
388
+ By default, `set_datafile(datafile)` merges the incoming datafile into the SDK's current datafile:
389
+
390
+ - top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile
391
+ - `segments` are merged, with incoming entries overriding existing ones
392
+ - `features` are merged, with incoming entries overriding existing ones
393
+
394
+ This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together.
395
+
396
+ ### Replacing
397
+
398
+ To fully replace the stored datafile, pass `true` as the second argument:
399
+
400
+ ```ruby
401
+ f.set_datafile(datafile_content, true)
402
+ ```
403
+
404
+ ### Loading datafiles on demand
405
+
406
+ Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront.
407
+
408
+ This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application:
409
+
410
+ ```ruby
411
+ require "open-uri"
412
+
413
+ f = Featurevisor.create_featurevisor({})
414
+
415
+ def load_datafile(f, target)
416
+ url = "https://cdn.yoursite.com/production/featurevisor-#{target}.json"
417
+ datafile = JSON.parse(URI.open(url).read, symbolize_names: true)
418
+
419
+ # merges into whatever was loaded before
420
+ f.set_datafile(datafile)
421
+ end
422
+
423
+ load_datafile(f, "products")
424
+
425
+ # later, when the user reaches checkout
426
+ load_datafile(f, "checkout")
427
+ ```
428
+
429
+ ### Updating datafile
430
+
431
+ You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly.
432
+
433
+ The triggers for setting the datafile again can be:
434
+
435
+ - periodic updates based on an interval (like every 5 minutes), or
436
+ - reacting to:
437
+ - a specific event in your application (like a user action), or
438
+ - an event served via websocket or server-sent events (SSE)
439
+
440
+ ### Interval-based update
441
+
442
+ Here's an example of using interval-based update:
443
+
444
+ ```ruby
445
+ require 'net/http'
446
+ require 'json'
447
+
448
+ def update_datafile(f, datafile_url)
449
+ loop do
450
+ sleep(5 * 60) # 5 minutes
451
+
452
+ begin
453
+ response = Net::HTTP.get_response(URI(datafile_url))
454
+ datafile_content = JSON.parse(response.body)
455
+ f.set_datafile(datafile_content)
456
+ rescue => e
457
+ # handle error
458
+ puts "Failed to update datafile: #{e.message}"
459
+ end
460
+ end
461
+ end
462
+
463
+ # Start the update thread
464
+ Thread.new { update_datafile(f, datafile_url) }
465
+ ```
466
+
467
+ ## Diagnostics
468
+
469
+ By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix.
470
+
471
+ ### Levels
472
+
473
+ Available diagnostic levels are `fatal`, `error`, `warn`, `info`, and `debug`.
474
+
475
+ Set the level during initialization or update it afterwards:
476
+
477
+ ```ruby
478
+ f = Featurevisor.create_featurevisor(log_level: "debug")
479
+ f.set_log_level("info")
480
+ ```
481
+
482
+ ### Handler
483
+
484
+ Use `on_diagnostic` to send structured diagnostics to your observability system:
485
+
486
+ ```ruby
487
+ f = Featurevisor.create_featurevisor(
488
+ log_level: "info",
489
+ on_diagnostic: ->(diagnostic) {
490
+ puts "#{diagnostic[:level]} #{diagnostic[:code]} #{diagnostic[:message]}"
491
+ }
492
+ )
493
+ ```
494
+
495
+ Every diagnostic has `:level`, `:code`, `:message`, and an object-shaped `:details` hash. Optional `:module`, `:moduleName`, and `:originalError` fields describe provenance. Evaluation metadata belongs in `:details`.
496
+
497
+ Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations.
498
+
499
+
500
+ ## Events
501
+
502
+ Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime.
503
+
504
+ You can listen to these events that can occur at various stages in your application:
505
+
506
+ ### `datafile_set`
507
+
508
+ ```ruby
509
+ unsubscribe = f.on('datafile_set') do |event|
510
+ revision = event[:revision] # new revision
511
+ previous_revision = event[:previousRevision]
512
+ revision_changed = event[:revisionChanged] # true if revision has changed
513
+
514
+ # list of feature keys that have new updates,
515
+ # and you should re-evaluate them
516
+ features = event[:features]
517
+
518
+ # handle here
519
+ end
520
+
521
+ # stop listening to the event
522
+ unsubscribe.call
523
+ ```
524
+
525
+ The `features` array will contain keys of features that have either been:
526
+
527
+ - added, or
528
+ - updated, or
529
+ - removed
530
+
531
+ compared to the previous datafile content that existed in the SDK instance.
532
+
533
+ The event also includes `replaced`, which is `true` when the datafile replaced the previous content instead of merging into it.
534
+
535
+ ### `context_set`
536
+
537
+ ```ruby
538
+ unsubscribe = f.on('context_set') do |event|
539
+ replaced = event[:replaced] # true if context was replaced
540
+ context = event[:context] # the new context
541
+
542
+ puts 'Context set'
543
+ end
544
+ ```
545
+
546
+ ### `sticky_set`
547
+
548
+ ```ruby
549
+ unsubscribe = f.on('sticky_set') do |event|
550
+ replaced = event[:replaced] # true if sticky features got replaced
551
+ features = event[:features] # list of all affected feature keys
552
+
553
+ puts 'Sticky features set'
554
+ end
555
+ ```
556
+
557
+ ### `error`
558
+
559
+ ```ruby
560
+ unsubscribe = f.on('error') do |event|
561
+ diagnostic = event[:diagnostic]
562
+ code = diagnostic[:code]
563
+ message = diagnostic[:message]
564
+
565
+ puts "Featurevisor error: #{code} #{message}"
566
+ end
567
+ ```
568
+
569
+ The `error` event is emitted for diagnostics reported with `level: "error"`.
570
+
571
+ ## Evaluation details
572
+
573
+ Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context:
574
+
575
+ ```ruby
576
+ # flag
577
+ evaluation = f.evaluate_flag(feature_key, context = {})
578
+
579
+ # variation
580
+ evaluation = f.evaluate_variation(feature_key, context = {})
581
+
582
+ # variable
583
+ evaluation = f.evaluate_variable(feature_key, variable_key, context = {})
584
+ ```
585
+
586
+ The returned object will always contain the following properties:
587
+
588
+ - `feature_key`: the feature key
589
+ - `reason`: the reason how the value was evaluated
590
+
591
+ And optionally these properties depending on whether you are evaluating a feature variation or a variable:
592
+
593
+ - `bucket_value`: the bucket value between 0 and 100,000
594
+ - `rule_key`: the rule key
595
+ - `error`: the error object
596
+ - `enabled`: if feature itself is enabled or not
597
+ - `variation`: the variation object
598
+ - `variation_value`: the variation value
599
+ - `variable_key`: the variable key
600
+ - `variable_value`: the variable value
601
+ - `variable_schema`: the variable schema
602
+
603
+ ## Modules
604
+
605
+ Modules allow you to intercept the evaluation process and customize SDK behavior.
606
+
607
+ ### Defining a module
608
+
609
+ A module is a simple hash with a unique recommended `name` and optional lifecycle functions:
610
+
611
+ If `setup` raises an exception, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present.
612
+
613
+ ```ruby
614
+ require 'featurevisor'
615
+
616
+ my_custom_module = {
617
+ # recommended, and used for duplicate detection/removal
618
+ name: 'my-custom-module',
619
+
620
+ # rest of the properties below are all optional per module
621
+
622
+ # setup once, when the module is registered
623
+ setup: ->(api) {
624
+ revision = api[:get_revision].call
625
+
626
+ api[:on_diagnostic].call(->(diagnostic) {
627
+ puts diagnostic[:message]
628
+ })
629
+ },
630
+
631
+ # before evaluation
632
+ before: ->(options) {
633
+ # update context before evaluation
634
+ options[:context] = options[:context].merge({
635
+ someAdditionalAttribute: 'value'
636
+ })
637
+ options
638
+ },
639
+
640
+ # after evaluation
641
+ after: ->(evaluation, options) {
642
+ reason = evaluation[:reason]
643
+ if reason == 'error'
644
+ # log error
645
+ return
646
+ end
647
+ },
648
+
649
+ # configure bucket key
650
+ bucket_key: ->(options) {
651
+ # return custom bucket key
652
+ options[:bucket_key]
653
+ },
654
+
655
+ # configure bucket value (between 0 and 100,000)
656
+ bucket_value: ->(options) {
657
+ # return custom bucket value
658
+ options[:bucket_value]
659
+ },
660
+
661
+ # cleanup when module is removed or SDK is closed
662
+ close: -> {
663
+ # cleanup here
664
+ }
665
+ }
666
+ ```
667
+
668
+ The module API passed to `setup` exposes:
669
+
670
+ - `get_revision`
671
+ - `on_diagnostic`
672
+ - `report_diagnostic`
673
+
674
+ ### Registering modules
675
+
676
+ You can register modules at the time of SDK initialization:
677
+
678
+ ```ruby
679
+ require 'featurevisor'
680
+
681
+ f = Featurevisor.create_featurevisor(
682
+ modules: [my_custom_module]
683
+ )
684
+ ```
685
+
686
+ Or after initialization:
687
+
688
+ ```ruby
689
+ remove_module = f.add_module(my_custom_module)
690
+
691
+ # remove later by calling the returned function
692
+ remove_module.call
693
+
694
+ # or remove by name
695
+ f.remove_module('my-custom-module')
696
+ ```
697
+
698
+ ## Child instance
699
+
700
+ When dealing with purely client-side applications, it is understandable that there is only one user involved, like in browser or mobile applications.
701
+
702
+ But when using Featurevisor SDK in server-side applications, where a single server instance can handle multiple user requests simultaneously, it is important to isolate the context for each request.
703
+
704
+ That's where child instances come in handy:
705
+
706
+ ```ruby
707
+ child_f = f.spawn({
708
+ # user or request specific context
709
+ userId: '123'
710
+ })
711
+ ```
712
+
713
+ Now you can pass the child instance where your individual request is being handled, and you can continue to evaluate features targeting that specific user alone:
714
+
715
+ ```ruby
716
+ is_enabled = child_f.is_enabled('my_feature')
717
+ variation = child_f.get_variation('my_feature')
718
+ variable_value = child_f.get_variable('my_feature', 'my_variable')
719
+ ```
720
+
721
+ Similar to parent SDK, child instances also support several additional methods:
722
+
723
+ - `set_context`
724
+ - `set_sticky`
725
+ - `is_enabled`
726
+ - `get_variation`
727
+ - `get_variable`
728
+ - `get_variable_boolean`
729
+ - `get_variable_string`
730
+ - `get_variable_integer`
731
+ - `get_variable_double`
732
+ - `get_variable_array`
733
+ - `get_variable_object`
734
+ - `get_variable_json`
735
+ - `get_all_evaluations`
736
+ - `on`
737
+ - `close`
738
+
739
+ ## Close
740
+
741
+ Both primary and child instances support a `.close()` method, that removes forgotten event listeners (via `on` method) and cleans up any potential memory leaks.
742
+
743
+ ```ruby
744
+ f.close()
745
+ ```
746
+
747
+ ## CLI usage
748
+
749
+ This package also provides a CLI tool for running your Featurevisor [project](https://featurevisor.com/docs/projects/)'s test specs and benchmarking against this Ruby SDK.
750
+
751
+ - Global installation: you can access it as `featurevisor`
752
+ - Local installation: you can access it as `bundle exec featurevisor`
753
+ - From this repository: you can access it as `bin/featurevisor`
754
+
755
+ ### Test
756
+
757
+ Learn more about testing [here](https://featurevisor.com/docs/testing/).
758
+
759
+ ```bash
760
+ $ bundle exec featurevisor test --projectDirectoryPath="/absolute/path/to/your/featurevisor/project"
761
+ ```
762
+
763
+ Additional options that are available:
764
+
765
+ ```bash
766
+ $ bundle exec featurevisor test \
767
+ --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \
768
+ --quiet|--verbose \
769
+ --onlyFailures \
770
+ --keyPattern="myFeatureKey" \
771
+ --assertionPattern="#1"
772
+ ```
773
+
774
+ The Ruby test runner builds base datafiles and Target datafiles in memory via `npx featurevisor build --json`. When an assertion contains `target`, it is evaluated against the matching Target datafile.
775
+
776
+ All three commands accept repeatable `--target=<target>` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI.
777
+
778
+ ### Test against local monorepo's example-1
779
+
780
+ ```bash
781
+ $ cd /absolute/path/to/featurevisor-ruby
782
+ $ bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures
783
+ $ make test-example-1
784
+ ```
785
+
786
+ ### Benchmark
787
+
788
+ Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking).
789
+
790
+ ```bash
791
+ $ bundle exec featurevisor benchmark \
792
+ --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \
793
+ --environment="production" \
794
+ --feature="myFeatureKey" \
795
+ --context='{"country": "nl"}' \
796
+ --n=1000
797
+ ```
798
+
799
+ ### Assess distribution
800
+
801
+ Learn more about assessing distribution [here](https://featurevisor.com/docs/cli/#assess-distribution).
802
+
803
+ ```bash
804
+ $ bundle exec featurevisor assess-distribution \
805
+ --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \
806
+ --environment=production \
807
+ --feature=foo \
808
+ --variation \
809
+ --context='{"country": "nl"}' \
810
+ --populateUuid=userId \
811
+ --populateUuid=deviceId \
812
+ --n=1000
813
+ ```
814
+
815
+ ## OpenFeature
816
+
817
+ The OpenFeature provider is published as a separate gem. This keeps OpenFeature code and dependencies out of applications that only use the Featurevisor SDK.
818
+
819
+ The provider currently requires Ruby 3.4 or newer because that is the minimum version supported by the official OpenFeature Ruby SDK.
820
+
821
+ Install the provider:
822
+
823
+ ```ruby
824
+ gem "featurevisor-openfeature", "~> 1.1"
825
+ ```
826
+
827
+ It installs the matching `featurevisor` gem and the official `openfeature-sdk` dependency. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version.
828
+
829
+ ```ruby
830
+ require "featurevisor/openfeature_provider"
831
+
832
+ provider = Featurevisor::OpenFeatureProvider.new(
833
+ datafile: datafile_content,
834
+ )
835
+
836
+ OpenFeature::SDK.configure do |config|
837
+ config.set_provider_and_wait(provider)
838
+ end
839
+
840
+ client = OpenFeature::SDK.build_client
841
+ enabled = client.fetch_boolean_value(
842
+ flag_key: "checkout",
843
+ default_value: false,
844
+ evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123"),
845
+ )
846
+ ```
847
+
848
+ Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, hashes, and JSON variables use the object resolver.
849
+
850
+ OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping.
851
+
852
+ You can pass any Featurevisor initialization options directly to the provider. These options are used to create the Featurevisor instance owned by the provider:
853
+
854
+ ```ruby
855
+ provider = Featurevisor::OpenFeatureProvider.new(
856
+ datafile: datafile_content,
857
+ targeting_key_field: "accountId",
858
+ key_separator: "/",
859
+ variation_key: "$variation",
860
+ )
861
+ ```
862
+
863
+ You can also reuse an existing Featurevisor instance:
864
+
865
+ ```ruby
866
+ featurevisor = Featurevisor.create_featurevisor(datafile: datafile_content)
867
+ provider = Featurevisor::OpenFeatureProvider.new(featurevisor: featurevisor)
868
+ ```
869
+
870
+ The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options hash.
871
+
872
+ See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages.
873
+
874
+ <!-- FEATUREVISOR_DOCS_END -->
875
+
876
+ ## Development
877
+
878
+ ### Setting up
879
+
880
+ After checking out the repository, run `make install` to install dependencies.
881
+
882
+ ### Running tests
883
+
884
+ ```bash
885
+ $ bundle exec rspec
886
+ $ make test-base
887
+ $ make test-example-1
888
+ ```
889
+
890
+ Build and verify both gems with:
891
+
892
+ ```bash
893
+ $ make build
894
+ ```
895
+
896
+ The build produces `featurevisor-VERSION.gem` and `featurevisor-openfeature-VERSION.gem`. The artifact verifier confirms that OpenFeature code and dependencies are absent from the base gem and present only in the provider gem.
897
+
898
+ ### Releasing
899
+
900
+ - Update the shared version in `lib/featurevisor/version.rb`
901
+ - Run `bundle install`
902
+ - Push commit to `main` branch
903
+ - Wait for CI to complete
904
+ - Tag the release with the same version number, for example `v1.1.0`
905
+ - The workflow verifies that the tag matches the shared version
906
+ - The workflow publishes `featurevisor` first, followed by `featurevisor-openfeature`
907
+
908
+ The gems are separate RubyGems packages built from the same repository. Publishing the base gem first ensures the provider's exact Featurevisor dependency is available when the provider is published. If the second push fails, rerun or retry the provider publication without republishing the existing base version.
909
+
910
+ ## License
911
+
912
+ MIT © [Fahad Heylaal](https://fahad19.com)
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "open_feature/sdk"
6
+ require_relative "../featurevisor"
7
+
8
+ module Featurevisor
9
+ class OpenFeatureProvider
10
+ Provider = OpenFeature::SDK::Provider
11
+
12
+ attr_reader :metadata, :featurevisor
13
+
14
+ def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", key_separator: ":", variation_key: "variation", on_track: nil, **featurevisor_options)
15
+ raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash)
16
+
17
+ @metadata = Provider::ProviderMetadata.new(name: "Featurevisor").freeze
18
+ @targeting_key_field = targeting_key_field.empty? ? "userId" : targeting_key_field
19
+ @key_separator = key_separator.empty? ? ":" : key_separator
20
+ @variation_key = variation_key.empty? ? "variation" : variation_key
21
+ @on_track = on_track
22
+ @datafile_error = nil
23
+ @shutdown = false
24
+ @owns_featurevisor = featurevisor.nil?
25
+ if featurevisor
26
+ @featurevisor = featurevisor
27
+ else
28
+ resolved_options = options.merge(featurevisor_options)
29
+ initial_datafile = resolved_options.delete(:datafile)
30
+ @featurevisor = Featurevisor.create_featurevisor(resolved_options)
31
+ end
32
+ @error_unsubscribe = @featurevisor.on("error", lambda do |event|
33
+ diagnostic = event[:diagnostic]
34
+ @datafile_error = diagnostic[:message] if diagnostic&.dig(:code) == "invalid_datafile"
35
+ end)
36
+ @datafile_unsubscribe = @featurevisor.on("datafile_set", ->(*) { @datafile_error = nil })
37
+ @featurevisor.set_datafile(initial_datafile, true) if @owns_featurevisor && initial_datafile
38
+ end
39
+
40
+ def init(_evaluation_context = nil); end
41
+ def shutdown
42
+ return if @shutdown
43
+
44
+ @shutdown = true
45
+ @error_unsubscribe&.call
46
+ @datafile_unsubscribe&.call
47
+ featurevisor.close if @owns_featurevisor
48
+ end
49
+ def hooks = []
50
+
51
+ def track(tracking_event_name:, evaluation_context: nil, tracking_event_details: nil)
52
+ @on_track&.call(tracking_event_name, evaluation_context, tracking_event_details)
53
+ end
54
+
55
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
56
+ resolve(flag_key, default_value, evaluation_context, :boolean)
57
+ end
58
+
59
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
60
+ resolve(flag_key, default_value, evaluation_context, :string)
61
+ end
62
+
63
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
64
+ resolve(flag_key, default_value, evaluation_context, :number)
65
+ end
66
+
67
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
68
+ resolve(flag_key, default_value, evaluation_context, :integer)
69
+ end
70
+
71
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
72
+ resolve(flag_key, default_value, evaluation_context, :float)
73
+ end
74
+
75
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
76
+ resolve(flag_key, default_value, evaluation_context, :object)
77
+ end
78
+
79
+ private
80
+
81
+ def resolve(flag_key, default_value, evaluation_context, expected_type)
82
+ return error(default_value, Provider::ErrorCode::PARSE_ERROR, @datafile_error) if @datafile_error
83
+
84
+ feature_key, selector = split_key(flag_key)
85
+ context = normalize(evaluation_context&.fields || {})
86
+ targeting_key = evaluation_context&.targeting_key
87
+ context[@targeting_key_field] = targeting_key if targeting_key && !targeting_key.empty?
88
+
89
+ if selector.nil? || selector.empty?
90
+ return type_mismatch(flag_key, default_value, expected_type) unless expected_type == :boolean
91
+ evaluation = featurevisor.evaluate_flag(feature_key, context)
92
+ value = evaluation[:enabled]
93
+ elsif selector == @variation_key
94
+ evaluation = featurevisor.evaluate_variation(feature_key, context)
95
+ value = evaluation[:variation_value] || evaluation.dig(:variation, :value)
96
+ else
97
+ evaluation = featurevisor.evaluate_variable(feature_key, selector, context)
98
+ value = evaluation[:variable_value]
99
+ if evaluation.dig(:variable_schema, :type) == "json" && value.is_a?(String)
100
+ begin
101
+ value = JSON.parse(value)
102
+ rescue JSON::ParserError
103
+ # Type validation below returns TYPE_MISMATCH when object was requested.
104
+ end
105
+ end
106
+ end
107
+
108
+ metadata = metadata_for(evaluation)
109
+ code = error_code(evaluation[:reason])
110
+ return error(default_value, code, error_message(evaluation), metadata) if code
111
+ value = default_value if value.nil?
112
+ value = normalize(value) if expected_type == :object
113
+ return type_mismatch(flag_key, default_value, expected_type, metadata) unless matches?(value, expected_type)
114
+
115
+ Provider::ResolutionDetails.new(
116
+ value: value,
117
+ reason: reason(evaluation[:reason]),
118
+ variant: variant(evaluation),
119
+ flag_metadata: metadata
120
+ )
121
+ end
122
+
123
+ def split_key(key)
124
+ index = key.index(@key_separator)
125
+ index ? [key[0...index], key[(index + @key_separator.length)..]] : [key, nil]
126
+ end
127
+
128
+ def metadata_for(evaluation)
129
+ metadata = {
130
+ "featureKey" => evaluation[:feature_key],
131
+ "featurevisorReason" => evaluation[:reason],
132
+ "schemaVersion" => featurevisor.get_schema_version
133
+ }
134
+ metadata["revision"] = featurevisor.get_revision if featurevisor.get_revision
135
+ {
136
+ variable_key: "variableKey",
137
+ rule_key: "ruleKey",
138
+ bucket_key: "bucketKey",
139
+ bucket_value: "bucketValue",
140
+ force_index: "forceIndex",
141
+ variable_override_index: "variableOverrideIndex"
142
+ }.each do |key, metadata_key|
143
+ metadata[metadata_key] = evaluation[key] unless evaluation[key].nil?
144
+ end
145
+ metadata
146
+ end
147
+
148
+ def reason(value)
149
+ return Provider::Reason::ERROR if %w[feature_not_found variable_not_found no_variations error].include?(value)
150
+ return Provider::Reason::TARGETING_MATCH if %w[required forced sticky rule variable_override_variation variable_override_rule].include?(value)
151
+ return Provider::Reason::SPLIT if value == "allocated"
152
+ return Provider::Reason::DISABLED if %w[disabled variation_disabled variable_disabled].include?(value)
153
+ Provider::Reason::DEFAULT
154
+ end
155
+
156
+ def error_code(value)
157
+ return Provider::ErrorCode::FLAG_NOT_FOUND if %w[feature_not_found variable_not_found no_variations].include?(value)
158
+ return Provider::ErrorCode::GENERAL if value == "error"
159
+ nil
160
+ end
161
+
162
+ def error_message(evaluation)
163
+ return evaluation[:error].message if evaluation[:error].respond_to?(:message)
164
+ return %(Feature "#{evaluation[:feature_key]}" was not found) if evaluation[:reason] == "feature_not_found"
165
+ return %(Variable "#{evaluation[:variable_key]}" was not found for feature "#{evaluation[:feature_key]}") if evaluation[:reason] == "variable_not_found"
166
+ return %(Feature "#{evaluation[:feature_key]}" has no variations) if evaluation[:reason] == "no_variations"
167
+ "Featurevisor evaluation failed"
168
+ end
169
+
170
+ def variant(evaluation) = evaluation[:variation_value] || evaluation.dig(:variation, :value)
171
+
172
+ def matches?(value, type)
173
+ case type
174
+ when :boolean then value == true || value == false
175
+ when :string then value.is_a?(String)
176
+ when :number then finite_number?(value)
177
+ when :integer then value.is_a?(Integer)
178
+ when :float then value.is_a?(Float) && value.finite?
179
+ when :object then value.is_a?(Hash) || value.is_a?(Array)
180
+ else false
181
+ end
182
+ end
183
+
184
+ def finite_number?(value)
185
+ value.is_a?(Numeric) && !value.is_a?(Complex) && (!value.respond_to?(:finite?) || value.finite?)
186
+ end
187
+
188
+ def normalize(value)
189
+ case value
190
+ when Time, DateTime then value.iso8601
191
+ when Hash then value.to_h { |key, item| [key.to_s, normalize(item)] }
192
+ when Array then value.map { |item| normalize(item) }
193
+ else value
194
+ end
195
+ end
196
+
197
+ def error(value, code, message, metadata = {})
198
+ Provider::ResolutionDetails.new(value: value, reason: Provider::Reason::ERROR, error_code: code, error_message: message, flag_metadata: metadata)
199
+ end
200
+
201
+ def type_mismatch(key, value, expected_type, metadata = {})
202
+ error(value, Provider::ErrorCode::TYPE_MISMATCH, %(Flag "#{key}" did not resolve to a #{expected_type} value), metadata)
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "featurevisor/openfeature_provider"
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: featurevisor-openfeature
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fahad Heylaal
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: featurevisor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 1.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 1.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: openfeature-sdk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.6.5
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.6.5
40
+ description: OpenFeature provider backed by the Featurevisor Ruby SDK
41
+ executables: []
42
+ extensions: []
43
+ extra_rdoc_files: []
44
+ files:
45
+ - LICENSE
46
+ - README.md
47
+ - lib/featurevisor-openfeature.rb
48
+ - lib/featurevisor/openfeature_provider.rb
49
+ homepage: https://featurevisor.com/docs/sdks/openfeature/
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ source_code_uri: https://github.com/featurevisor/featurevisor-ruby
54
+ documentation_uri: https://featurevisor.com/docs/sdks/ruby/#openfeature
55
+ bug_tracker_uri: https://github.com/featurevisor/featurevisor-ruby/issues
56
+ allowed_push_host: https://rubygems.org
57
+ rubygems_mfa_required: 'true'
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 3.4.0
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.6.9
73
+ specification_version: 4
74
+ summary: OpenFeature provider for Featurevisor
75
+ test_files: []