Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
ruby: ['3.4', '4.0']
steps:
- uses: actions/checkout@v6
Comment thread
deleteLater marked this conversation as resolved.
with:
persist-credentials: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rake
- run: gem build featbit-openfeature-provider.gemspec
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/.bundle/
/vendor/
/Gemfile.lock
/*.gem
/pkg/
/coverage/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Changelog

## Unreleased

- Add FeatBit evaluation through OpenFeature, including typed values, context mapping,
resolution details, lifecycle events, and experiment tracking.
7 changes: 7 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

source "https://rubygems.org"
gemspec

gem "rake", "~> 13.0"
gem "rspec", "~> 3.13"
178 changes: 178 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# FeatBit OpenFeature Provider for Ruby

Use [FeatBit](https://featbit.co) feature flags through the
[OpenFeature Ruby SDK](https://openfeature.dev/docs/reference/sdks/server/ruby/).
Evaluation and analytics are handled by the [FeatBit Ruby Server SDK](https://github.com/featbit/featbit-ruby-server-sdk).

## Requirements and installation

Ruby 3.4 or newer. This provider uses `openfeature-sdk ~> 0.6.5` and
`featbit-server-sdk ~> 0.1.0`.

Until the first RubyGems release, add the repository to your Gemfile:

```ruby
gem "featbit-openfeature-provider", git: "https://github.com/featbit/openfeature-provider-ruby-server"
```

Run `bundle install`. For local development, use `path:` pointing at this checkout.

## Quick start

```ruby
require "featbit/openfeature"

provider = FeatBit::OpenFeature::Provider.new(
FeatBit::Options.new(
env_secret: ENV.fetch("FEATBIT_ENV_SECRET"),
streaming_url: ENV.fetch("FEATBIT_STREAMING_URL", "wss://app-eval.featbit.co"),
event_url: ENV.fetch("FEATBIT_EVENT_URL", "https://app-eval.featbit.co"),
start_wait: 5
)
)

# Raises OpenFeature::SDK::ProviderInitializationError if FeatBit is not ready.
OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) }
client = OpenFeature::SDK.build_client
context = OpenFeature::SDK::EvaluationContext.new(
targeting_key: "user-123", name: "Alice", country: "FR", plan: "pro"
)

enabled = client.fetch_boolean_value(
flag_key: "new-checkout", default_value: false, evaluation_context: context
)

details = client.fetch_boolean_details(
flag_key: "new-checkout", default_value: false, evaluation_context: context
)
puts "value=#{enabled} variant=#{details.variant} reason=#{details.reason} error=#{details.error_code}"

# Call during graceful application shutdown to close connections and flush events.
OpenFeature::SDK.shutdown
```

Create one provider per application process and reuse the OpenFeature client.
For prefork servers, create the provider in each worker after fork.
The FeatBit client starts during provider construction and waits up to `start_wait`.
OpenFeature calls `init` to check readiness; `config.set_provider(provider)` offers
nonblocking OpenFeature registration if you do not need to wait for registration.

An existing client can be supplied with
`FeatBit::OpenFeature::Provider.new(client: featbit_client)`. The provider takes
ownership: replacement or OpenFeature shutdown closes that client. Do not share
one FeatBit client between independently managed providers. A closed provider
cannot restart; construct a new instance.

## Evaluation context

| OpenFeature field | FeatBit user field |
| --- | --- |
| `targeting_key` | Required non-empty string user key |
| `name` | Optional string user name |
| Other fields | Custom attributes, with their values preserved |

Missing or empty targeting keys return `TARGETING_KEY_MISSING`. Non-string keys,
non-string names, and unsupported context objects return `INVALID_CONTEXT`.
The provider does not invent anonymous identities or use `key` as a fallback.
Built-in FeatBit user properties retain the SDK's precedence over custom attributes.
Contexts are not modified. Custom attribute matching and analytics serialization
follow the FeatBit SDK's behavior; use scalar attributes for targeting rules.

OpenFeature merges API, client, transaction, and invocation contexts before calling
the provider. A targeting key may therefore be set at any of those scopes.

## Values and resolution details

The provider supports `fetch_boolean_value`, `fetch_string_value`,
`fetch_number_value`, `fetch_integer_value`, `fetch_float_value`, and
`fetch_object_value`, plus OpenFeature's corresponding `fetch_*_details` methods.
Objects may be hashes or arrays. Integer evaluation truncates numeric values toward
zero; float evaluation converts numeric values to Float. Incompatible types return
the caller's default with `TYPE_MISMATCH`.

Successful details include the FeatBit variation ID as `variant`, plus
`featbit.reason`, `featbit.flag_name` (when available), and `featbit.in_experiment`
in flag metadata.

| FeatBit reason/error | OpenFeature reason/error |
| --- | --- |
| Flag off | `DISABLED` |
| User target or rule match | `TARGETING_MATCH` |
| Fallthrough | `DEFAULT` |
| Client not ready | `ERROR` / `PROVIDER_NOT_READY` |
| Flag not found | `ERROR` / `FLAG_NOT_FOUND` |
| Wrong type | `ERROR` / `TYPE_MISMATCH` |
| User not specified | `ERROR` / `TARGETING_KEY_MISSING` |
| Other evaluation errors | `ERROR` / `GENERAL` |

Errors return the caller's default without a variant. FeatBit does not expose a
separate parse error category, so malformed flag evaluation errors map to `GENERAL`.
Evaluation analytics are produced by FeatBit's `variation_detail`; a successful
FeatBit evaluation can record an event even if the provider subsequently rejects
its type.

## Events and tracking

The provider forwards FeatBit readiness, interrupted synchronization, and flag
changes as `PROVIDER_READY`, `PROVIDER_STALE`, and
`PROVIDER_CONFIGURATION_CHANGED` (with `flags_changed`). Failed or closed clients
emit `PROVIDER_ERROR`. Cached flags remain available during synchronization
interruptions. Shutdown removes the provider's listeners.

```ruby
client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) do |event|
puts event[:flags_changed].inspect
end

client.track(
"purchase",
evaluation_context: context,
tracking_event_details: OpenFeature::SDK::TrackingEventDetails.new(value: 19.95)
)
```

Tracking requires a valid targeting key. Omitted metric values default to `1.0`.
Extra tracking fields are ignored because the FeatBit tracking API accepts only a
user, event name, and numeric value. Invalid tracking contexts produce no event.

## Offline use

```ruby
require "json"

provider = FeatBit::OpenFeature::Provider.new(
FeatBit::Options.new(offline: true, bootstrap: JSON.parse(File.read("bootstrap.json")))
)
```

Supply a full FeatBit data-sync payload containing feature flags and segments.
Offline mode disables networking and analytics. Without initialized bootstrap
data the provider reports `PROVIDER_NOT_READY`.

## Console example

The console example evaluates a boolean flag against a FeatBit environment:

```sh
FEATBIT_ENV_SECRET="your-environment-secret" bundle exec ruby examples/console.rb
```

Enter a boolean flag key at the prompt to evaluate it. The prompt repeats so
multiple flags can be evaluated in one session. Enter `exit`, `quit`, or `q`
to stop the application.

Set `FEATBIT_TARGETING_KEY` to evaluate for a different user. The streaming and
event URLs default to FeatBit Cloud and can be overridden with
`FEATBIT_STREAMING_URL` and `FEATBIT_EVENT_URL`.

## Development

```sh
bundle install
bundle exec rake
gem build featbit-openfeature-provider.gemspec
```

Tests run against the released SDK gems with real offline FeatBit evaluation,
including OpenFeature context merging, defaults, details, events, and tracking.
No FeatBit service or environment secret is required.
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

require "bundler/gem_tasks"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)
task default: :spec
53 changes: 53 additions & 0 deletions examples/console.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

require "featbit/openfeature"

env_secret = ENV.fetch("FEATBIT_ENV_SECRET")

provider = FeatBit::OpenFeature::Provider.new(
FeatBit::Options.new(
env_secret: env_secret,
streaming_url: ENV.fetch("FEATBIT_STREAMING_URL", "wss://app-eval.featbit.co"),
event_url: ENV.fetch("FEATBIT_EVENT_URL", "https://app-eval.featbit.co"),
start_wait: 5
)
)

begin
OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) }
client = OpenFeature::SDK.build_client
context = OpenFeature::SDK::EvaluationContext.new(
targeting_key: ENV.fetch("FEATBIT_TARGETING_KEY", "console-user"),
name: ENV.fetch("FEATBIT_USER_NAME", "Console User")
)

loop do
print "Enter a boolean flag key (or 'exit' to quit): "
input = $stdin.gets
break if input.nil?

flag_key = input.strip
break if %w[exit quit q].include?(flag_key.downcase)

if flag_key.empty?
puts "Flag key cannot be empty."
next
end

details = client.fetch_boolean_details(
flag_key: flag_key,
default_value: false,
evaluation_context: context
)

puts "value: #{details.value.inspect}"
puts "variant: #{details.variant.inspect}"
puts "reason: #{details.reason.inspect}"
puts "error_code: #{details.error_code.inspect}"
puts "error_message: #{details.error_message.inspect}"
puts "flag_metadata: #{details.flag_metadata.inspect}"
puts
end
ensure
OpenFeature::SDK.shutdown
end
24 changes: 24 additions & 0 deletions featbit-openfeature-provider.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require_relative "lib/featbit/openfeature/version"

source_code_uri = "https://github.com/featbit/openfeature-provider-ruby-server"

Gem::Specification.new do |spec|
spec.name = "featbit-openfeature-provider"
spec.version = FeatBit::OpenFeature::VERSION
spec.authors = ["FeatBit"]
spec.email = ["contact@featbit.co"]
spec.summary = "FeatBit OpenFeature provider for Ruby server applications"
spec.description = "Evaluate FeatBit feature flags through the OpenFeature Ruby SDK."
spec.homepage = "https://www.featbit.co/"
spec.license = "Apache-2.0"
spec.required_ruby_version = ">= 3.4"
spec.metadata["source_code_uri"] = source_code_uri
spec.metadata["bug_tracker_uri"] = "#{source_code_uri}/issues"
spec.metadata["rubygems_mfa_required"] = "true"
spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"]
spec.require_paths = ["lib"]
spec.add_dependency "featbit-server-sdk", "~> 0.1.0"
spec.add_dependency "openfeature-sdk", "~> 0.6.5"
end
8 changes: 8 additions & 0 deletions lib/featbit/openfeature.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

require "featbit"
require "open_feature/sdk"
require_relative "openfeature/version"
require_relative "openfeature/context_converter"
require_relative "openfeature/details_converter"
require_relative "openfeature/provider"
39 changes: 39 additions & 0 deletions lib/featbit/openfeature/context_converter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module FeatBit
module OpenFeature
class ContextConverter
class InvalidContext < StandardError
attr_reader :error_code

def initialize(error_code, message)
@error_code = error_code
super(message)
end
end

def self.convert(context)
codes = ::OpenFeature::SDK::Provider::ErrorCode
unless context.nil? || context.is_a?(::OpenFeature::SDK::EvaluationContext)
raise InvalidContext.new(codes::INVALID_CONTEXT, "Expected an OpenFeature evaluation context")
end

key = context&.targeting_key
if key.nil? || key == ""
raise InvalidContext.new(codes::TARGETING_KEY_MISSING, "A non-empty targeting_key is required")
end
unless key.is_a?(String)
raise InvalidContext.new(codes::INVALID_CONTEXT, "targeting_key must be a string")
end

name = context.field("name")
unless name.nil? || name.is_a?(String)
raise InvalidContext.new(codes::INVALID_CONTEXT, "name must be a string")
end

custom = context.fields.reject { |field, _| %w[targeting_key name].include?(field) }
FeatBit::User.new(key, name: name, custom: custom)
end
end
end
end
Loading