diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..858ab5a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ["3.1", "3.4"] + steps: + - uses: actions/checkout@v5 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - run: bundle install --jobs 4 --retry 3 + - run: bundle exec rspec + - run: bundle exec rubocop + - run: bundle exec ruby scripts/resource_audit.rb + - run: gem build featbit-server-sdk.gemspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f65ee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/.bundle/ +/coverage/ +/pkg/ +/vendor/bundle/ +/*.gem +/Gemfile.lock +/examples/*/.bundle/ +/examples/*/Gemfile.lock +/examples/Rails/log/ +/examples/Rails/tmp/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..854c87f --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,50 @@ +AllCops: + TargetRubyVersion: 3.1 + NewCops: enable + SuggestExtensions: false + Exclude: + - "vendor/**/*" + +Layout/EndOfLine: + Enabled: false + +Layout/LineLength: + Max: 140 + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/Documentation: + Enabled: false + +Metrics/AbcSize: + Max: 100 + +Metrics/ClassLength: + Max: 250 + +Metrics/BlockLength: + Exclude: + - "spec/**/*" + +Metrics/CyclomaticComplexity: + Max: 50 + +Metrics/MethodLength: + Max: 35 + +Metrics/PerceivedComplexity: + Max: 50 + +Metrics/ParameterLists: + Max: 20 + +Style/OptionalBooleanParameter: + Enabled: false + +Naming/PredicateMethod: + AllowedMethods: + - close + - enqueue + - flush + - post_batch diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b006d34 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.1.0 + +- Initial FeatBit Ruby Server SDK. +- Local typed evaluation with variation IDs and evaluation reasons. +- WebSocket data synchronization, event batching, status listeners, offline bootstrap, and safe public APIs. +- Console and Rails examples aligned with the .NET Server SDK documentation. +- Hardened event shutdown, reconnect cleanup, cyclic segment evaluation, immutable users, and per-entity patch versions. +- Fixed live WebSocket token encoding and callback context handling. +- Matched FeatBit's event wire types and enforced configured batch limits during explicit flush. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..453a0f7 --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "rake", "~> 13.2" +gem "rspec", "~> 3.13" +gem "rubocop", "~> 1.75", require: false diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..1824b60 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,77 @@ +PATH + remote: . + specs: + featbit-server-sdk (0.1.0) + logger (~> 1.6) + websocket-client-simple (~> 0.9) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + base64 (0.3.0) + diff-lcs (1.6.2) + event_emitter (0.2.6) + json (2.21.1) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.6.4) + mutex_m (0.3.0) + parallel (2.1.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.4.2) + regexp_parser (2.12.0) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.88.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + websocket (1.2.11) + websocket-client-simple (0.9.0) + base64 + event_emitter + mutex_m + websocket + +PLATFORMS + x64-mingw-ucrt + +DEPENDENCIES + featbit-server-sdk! + rake (~> 13.2) + rspec (~> 3.13) + rubocop (~> 1.75) + +BUNDLED WITH + 2.6.9 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3da8ba --- /dev/null +++ b/README.md @@ -0,0 +1,274 @@ +# FeatBit Server-Side SDK for Ruby + +## Introduction + +This is the Ruby Server-Side SDK for the 100% open-source feature flag management platform [FeatBit](https://github.com/featbit/featbit). + +The SDK is designed for multi-user server applications such as Rails services, background workers, and command-line applications. Create one `FeatBit::Client` per application process and reuse it for the lifetime of that process. + +## Data Synchronization + +The SDK maintains a WebSocket connection to the FeatBit evaluation service. Feature flags and segments are synchronized into a thread-safe in-memory store, so flag evaluation is local and does not make a network request. + +Changes are pushed to the SDK as full snapshots or incremental patches. Interrupted connections are retried automatically with capped exponential backoff, and the SDK sends periodic heartbeat messages. Use [offline mode](#offline-mode) when the application must provide its own bootstrap data. + +## Get Started + +### Installation + +Add the SDK to your `Gemfile`: + +```ruby +gem "featbit-server-sdk" +``` + +Then run: + +```sh +bundle install +``` + +### Prerequisite + +Before using the SDK, obtain the environment secret and SDK URLs from your FeatBit environment: + +- [How to get the environment secret](https://docs.featbit.co/sdk/faq#how-to-get-the-environment-secret) +- [How to get the SDK URLs](https://docs.featbit.co/sdk/faq#how-to-get-the-sdk-urls) + +Keep the environment secret on the server. Do not expose it to browsers or commit it to source control. + +### Quick Start + +```ruby +require "featbit" + +options = 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 +) + +client = FeatBit::Client.new(options) + +unless client.initialized? + warn "FeatBit failed to initialize; variation calls will return fallback values" +end + +flag_key = "game-runner" +user = FeatBit::User.new("anonymous", name: "Anonymous", custom: { country: "FR" }) + +value = client.bool_variation(flag_key, user, false) +detail = client.variation_detail(flag_key, user, false) + +puts "flag=#{flag_key} value=#{value} variation_id=#{detail.variation_id} reason=#{detail.reason}" + +# Close the client during application shutdown so queued insight events are flushed. +client.close +``` + +### Examples + +- [Console](/examples/Console) +- [Rails](/examples/Rails) + +## SDK + +### `FeatBit::Client` + +`FeatBit::Client` owns the WebSocket synchronizer, in-memory flag store, evaluator, and event processor. Applications should instantiate a single client for each process and close it during graceful shutdown. + +#### Default FeatBit Cloud endpoints + +```ruby +client = FeatBit::Client.new( + FeatBit::Options.new(env_secret: ENV.fetch("FEATBIT_ENV_SECRET")) +) +``` + +#### Custom endpoints and logger + +```ruby +require "logger" + +options = FeatBit::Options.new( + env_secret: ENV.fetch("FEATBIT_ENV_SECRET"), + streaming_url: "ws://localhost:5100", + event_url: "http://localhost:5100", + start_wait: 3, + connect_timeout: 5, + read_timeout: 10, + logger: Logger.new($stdout, level: Logger::INFO) +) + +client = FeatBit::Client.new(options) +``` + +#### Status + +The status provider exposes the synchronization lifecycle without throwing into application code: + +```ruby +client.status_provider.status +client.status_provider.ready? +client.status_provider.wait_until_ready(5) + +listener_id = client.status_provider.add_listener do |status, message| + puts "FeatBit status=#{status} message=#{message}" +end + +client.status_provider.remove_listener(listener_id) +``` + +Possible states are `starting`, `ready`, `interrupted`, `offline`, `failed`, and `closed`. + +#### Logging + +Pass any logger compatible with Ruby's standard `Logger` interface through `FeatBit::Options`. Transport, evaluation, listener, and event delivery failures are logged without including the environment secret. + +### `FeatBit::User` + +A user has a required unique key, an optional name, and optional custom attributes. Built-in and custom attributes can be referenced by targeting rules and are included in analytics events. + +```ruby +user = FeatBit::User.new( + "a-unique-user-key", + name: "Bob", + custom: { age: 15, country: "FR", plan: "pro" } +) +``` + +User attributes are defensively copied and frozen so the same user can safely be shared by concurrent evaluations. + +### Evaluating flags + +Evaluation is synchronous and local. Every call accepts a flag key, user, and fallback value. If the client is not ready, the flag is missing, the value has the wrong type, or an internal error occurs, the fallback is returned instead of raising into application code. + +```ruby +enabled = client.bool_variation("game-runner", user, false) +title = client.string_variation("page-title", user, "Welcome") +price = client.number_variation("price", user, 0) +config = client.json_variation("checkout-config", user, {}) + +detail = client.variation_detail("game-runner", user, false) +puts detail.value +puts detail.variation_id +puts detail.reason +puts detail.error_kind +puts detail.error_message +``` + +Available APIs: + +- `variation` and `variation_detail` +- `bool_variation` +- `string_variation` +- `number_variation` +- `json_variation` + +### Flag change notifications + +```ruby +listener_id = client.add_flag_change_listener do |flag_key| + puts "configuration changed for #{flag_key}" +end + +client.remove_flag_change_listener(listener_id) +``` + +Callbacks run outside internal locks. Exceptions raised by application callbacks are logged and isolated. + +### Offline Mode + +Offline mode disables WebSocket synchronization and event delivery. Supply a full FeatBit data-sync payload to evaluate locally: + +```ruby +require "json" + +bootstrap = JSON.parse(File.read("featbit-bootstrap.json")) +options = FeatBit::Options.new(offline: true, bootstrap: bootstrap) +client = FeatBit::Client.new(options) +``` + +An existing snapshot can be downloaded from the evaluation service: + +```sh +curl -H "Authorization: " \ + https://app-eval.featbit.co/api/public/sdk/server/latest-all \ + > featbit-bootstrap.json +``` + +The bootstrap format is owned by FeatBit and may evolve; prefer downloading an actual snapshot rather than constructing one manually. + +### Disable Events Collection + +Online synchronization can remain enabled while analytics events are disabled: + +```ruby +options = FeatBit::Options.new( + env_secret: ENV.fetch("FEATBIT_ENV_SECRET"), + disable_events: true +) +``` + +### Experiments (A/B/n Testing) + +Successful flag evaluations automatically enqueue variation events. Custom metric events can be sent with `track`: + +```ruby +client.bool_variation("new-checkout", user, false) +client.track(user, "checkout_completed", 99.0, properties: { currency: "CNY" }) +client.flush +``` + +Call `track` after evaluating the related experiment flag. `numeric_value` defaults to `1.0`. + +### Thread safety and shutdown + +- Share one client across application threads; do not create a client per request. +- Flag evaluation performs no network I/O. +- Event enqueue is non-blocking and bounded; overload increments `dropped_events`. +- Status and flag callbacks execute outside internal locks. +- `close` is thread-safe and idempotent, flushes accepted events, stops background workers, and prevents new events. +- Public client methods contain ordinary internal failures and return fallbacks or `false`. + +## Supported Ruby versions + +- Ruby 3.1 and later +- MRI Ruby is covered by the CI test matrix + +## OpenFeature + +Use the separate [FeatBit OpenFeature Ruby provider](https://github.com/featbit/openfeature-provider-ruby-server) to access this SDK through the standard OpenFeature API. + +## Development and review checks + +```sh +bundle install +bundle exec rspec +bundle exec rubocop +bundle exec ruby scripts/resource_audit.rb +gem build featbit-server-sdk.gemspec +``` + +The test suite covers evaluation precedence, typed values, segments and cyclic segment protection, protocol payloads, full/patch synchronization, equal-timestamp updates, concurrent data access, bounded event queues, concurrent/idempotent shutdown, thread cleanup, listener mutation, and exception isolation. + +See [docs/REVIEW.md](/docs/REVIEW.md) for the code-review findings, fixes, and verification matrix. + +For an opt-in live check against FeatBit Cloud: + +```sh +FEATBIT_ENV_SECRET="..." \ +FEATBIT_FLAG_KEY="game-runner" \ +bundle exec ruby scripts/live_integration_check.rb +``` + +## Getting support + +- Ask questions in [FeatBit Slack](https://join.slack.com/t/featbit/shared_invite/zt-1ew5e2vbb-x6Apan1xZOaYMnFzqZkGNQ). +- Report bugs or request features in [GitHub Issues](https://github.com/featbit/featbit-ruby-server-sdk/issues/new). + +## See Also + +- [FeatBit documentation](https://docs.featbit.co) +- [Connect an SDK](https://docs.featbit.co/getting-started/connect-an-sdk) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..a6f5c0b --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) +task default: :spec diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..32300e7 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,40 @@ +# FeatBit Ruby Server SDK design + +## Scope + +The SDK owns FeatBit transport, storage, evaluation, event delivery, logging, lifecycle, concurrency, and safe application-facing APIs. OpenFeature-specific types remain in the separate Provider gem. + +## Components + +### 0. `FeatBit::Options` + +`Options` is an immutable configuration value containing the environment secret, streaming/event endpoints, startup wait, offline bootstrap, event queue limits, timeouts, retry delay, logger, and injectable factories for testing. Derived endpoints are `/streaming` and `/api/public/insight/track`. Invalid values are normalized to safe defaults and `valid?` reports whether online operation is possible. + +### 1. `WebSocketDataSynchronizer` + +The synchronizer authenticates with the FeatBit token format and the environment secret header. On open it sends a `data-sync` request containing the local timestamp. It accepts `full` and `patch` envelopes, updates the data store, broadcasts affected flag keys, reports lifecycle states, and reconnects with capped exponential delay. Parsing and callback errors are contained inside the component. + +### 2. `EventProcessor` + +The event processor uses a bounded `SizedQueue` and one worker thread. Producers use non-blocking enqueue; a full queue increments `dropped_events` rather than delaying business requests. It flushes on batch size, interval, explicit `flush`, and `close`, posting JSON to the FeatBit insight endpoint. Offline and disabled-event configurations use `NullEventProcessor`. + +### 3. `Evaluator` + +Evaluation is local and synchronous. Precedence is: disabled variation, explicit target, first matching rule, then fallthrough. Conditions cover numeric comparison, equality, containment, lists, prefixes/suffixes, booleans, regular expressions, and segment membership. Percentage rollout uses FeatBit's MD5/little-endian bucketing algorithm. Results include typed value, reason, variation ID, flag key/name, and structured errors. + +### 4. Logging + +Ruby's standard `Logger` is accepted through `Options`. Internal transport, listener, event, and evaluation failures are logged without exposing secrets or raising into user code. + +### 5. Thread safety, performance, and exception boundary + +- Data snapshots are replaced under a mutex and then read as frozen structures; callers receive defensive copies. +- Status and flag listeners are copied under lock and invoked outside the lock, avoiding deadlocks and allowing listener mutation during callbacks. +- Evaluation does no network I/O. +- Event enqueue is bounded and non-blocking. +- Public client evaluation methods return defaults/details on failure; lifecycle and event APIs return booleans. +- `close` is idempotent. + +## Verification + +The suite covers offline evaluation, targets, rules, segments, typed JSON, variation IDs, immutable storage, version ordering, concurrent readers/writers, bounded events, WebSocket data parsing, listener concurrency, and public exception isolation. The opt-in live check additionally verifies authentication, WebSocket full synchronization, local evaluation of remote data, event delivery, concurrent calls, status reporting, and worker cleanup against FeatBit Cloud. diff --git a/docs/REVIEW.md b/docs/REVIEW.md new file mode 100644 index 0000000..4e3a7bd --- /dev/null +++ b/docs/REVIEW.md @@ -0,0 +1,45 @@ +# Ruby Server SDK review + +## Scope + +The review covers API safety, FeatBit wire compatibility, evaluation correctness, concurrency, shutdown, memory retention, redundant work, documentation, examples, and packaging. The .NET Server SDK is the documentation and lifecycle reference; the Python Server SDK and FeatBit evaluation-server models are protocol references. + +## Findings and fixes + +| Priority | Area | Finding | Resolution | +| --- | --- | --- | --- | +| Critical | WebSocket | Connection-token encoding called a method that Ruby strings do not implement. A broad rescue returned the raw environment secret, so local mocks passed while FeatBit Cloud rejected the connection. | Corrected number slicing and added a test that decodes the token, reconstructs the secret, and validates its timestamp. | +| Critical | WebSocket | `websocket-client-simple` executes callbacks with the socket as `self`; callbacks that captured synchronizer instance variables failed only with the real transport. | Bound synchronizer methods before registering callbacks and tested the gem's callback execution semantics. | +| High | Events | Typed boolean/number/JSON evaluation values were sent directly, while FeatBit's event model requires `variation.value` to be a string. | Kept typed public results and added wire-only serialization for event values. | +| High | Events | Explicit flush could drain more than the configured batch size into one HTTP request. | Partitioned every delivery by `events_batch_size`, including explicit flush and close. | +| High | Shutdown | Flush/stop commands shared the bounded event queue and could block forever when it was full. | Added a separate unbounded control queue, acknowledgements, thread-safe idempotent close, and queue-full shutdown tests. | +| High | Data store | A single global version rejected valid updates to different entities that shared a timestamp. | Track versions per flag and segment while retaining the global snapshot version. | +| High | Evaluator | Mutually recursive segments could overflow the stack. | Added per-evaluation cycle detection and a cyclic-segment regression test. | +| Medium | Immutability | Nested custom user attributes remained mutable after client construction. | Deep-copied and recursively froze nested hashes, arrays, and strings. | +| Medium | Reconnect | A failed socket was not always closed before reconnect. | Close failed transports and suppress expected close-time errors. | + +## Concurrency and resource review + +- Data-store snapshots and version maps are mutex protected and defensively copied. +- Evaluation remains local and performs no network I/O. +- Event producers never wait for queue capacity; overflow is counted in `dropped_events`. +- Listener collections are copied under lock and invoked outside the lock. +- Client and event-processor shutdown are safe under concurrent repeated calls. +- Rails creates one lazy client per worker process, avoiding a WebSocket opened before a process fork. +- `scripts/resource_audit.rb` exercises 100 event-processor lifecycles and 80,000 evaluations across 16 threads. It fails on named worker-thread leaks, concurrent errors, or material post-GC heap retention. + +## Redundancy review + +No duplicate transport or evaluator implementation remains. Small normalization helpers stay component-local where merging them would couple wire parsing, storage, and evaluation behavior. The Console and Rails examples share the public SDK API; Rails-specific singleton and process-lifecycle code remains isolated in `FeatBitClientRegistry`. + +## Verification matrix + +| Check | Coverage | +| --- | --- | +| Unit/integration tests | Options, data store, evaluator, WebSocket messages, events, status, public exception boundary, examples | +| Static analysis | Entire gem, specs, scripts, Console and Rails example Ruby files | +| Resource audit | 80,000 concurrent evaluations, heap-retention threshold, 100 start/stop cycles, worker-thread cleanup | +| FeatBit Cloud | Server-key authentication, full WebSocket sync, remote flag evaluation, event track/flush, concurrent access, ready status, clean close | +| Packaging | Gem build and metadata validation | + +The live check reads credentials only from environment variables. Secrets are not printed, persisted, or committed. diff --git a/examples/Console/Gemfile b/examples/Console/Gemfile new file mode 100644 index 0000000..1bbfe55 --- /dev/null +++ b/examples/Console/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "featbit-server-sdk", path: "../.." diff --git a/examples/Console/README.md b/examples/Console/README.md new file mode 100644 index 0000000..6866cb4 --- /dev/null +++ b/examples/Console/README.md @@ -0,0 +1,18 @@ +# Console example + +This example connects to FeatBit, evaluates a flag locally, sends a custom event, flushes, and closes the SDK. + +```sh +cd examples/Console +bundle install + +FEATBIT_ENV_SECRET="..." \ +FEATBIT_FLAG_KEY="game-runner" \ +bundle exec ruby app.rb +``` + +Optional variables: + +- `FEATBIT_STREAMING_URL` defaults to `wss://app-eval.featbit.co` +- `FEATBIT_EVENT_URL` defaults to `https://app-eval.featbit.co` +- `FEATBIT_USER_KEY` defaults to `console-user` diff --git a/examples/Console/app.rb b/examples/Console/app.rb new file mode 100644 index 0000000..919d3ca --- /dev/null +++ b/examples/Console/app.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "featbit" + +options = 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: 10 +) + +client = FeatBit::Client.new(options) +flag_key = ENV.fetch("FEATBIT_FLAG_KEY", "game-runner") +user = FeatBit::User.new( + ENV.fetch("FEATBIT_USER_KEY", "console-user"), + name: "Ruby Console Example", + custom: { application: "console", language: "ruby" } +) + +begin + warn "FeatBit is not ready; the fallback value will be used" unless client.initialized? + + detail = client.variation_detail(flag_key, user, false) + puts "flag=#{flag_key} value=#{detail.value.inspect} variation_id=#{detail.variation_id.inspect} reason=#{detail.reason}" + + client.track(user, "ruby_console_example", 1.0) + warn "FeatBit events could not be flushed" unless client.flush +ensure + client.close +end diff --git a/examples/Rails/Gemfile b/examples/Rails/Gemfile new file mode 100644 index 0000000..53459cb --- /dev/null +++ b/examples/Rails/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "featbit-server-sdk", path: "../.." +gem "puma", "~> 6.0" +gem "rails", "~> 7.2" +gem "tzinfo-data", platforms: %i[windows jruby] diff --git a/examples/Rails/README.md b/examples/Rails/README.md new file mode 100644 index 0000000..bef3ba8 --- /dev/null +++ b/examples/Rails/README.md @@ -0,0 +1,20 @@ +# Rails example + +This minimal Rails application exposes `GET /flags/:key?user_key=...` and returns FeatBit evaluation details as JSON. + +The `FeatBitClientRegistry` creates the client lazily on the first request. This keeps background threads out of a preloaded master process and guarantees one client per Rails worker process. `at_exit` closes the client during graceful shutdown. + +```sh +cd examples/Rails +bundle install + +FEATBIT_ENV_SECRET="..." bundle exec rails server +``` + +Then request: + +```sh +curl "http://localhost:3000/flags/game-runner?user_key=rails-user" +``` + +For Puma cluster mode, keep the registry lazy so each worker creates its own WebSocket connection after fork. diff --git a/examples/Rails/Rakefile b/examples/Rails/Rakefile new file mode 100644 index 0000000..c4f9523 --- /dev/null +++ b/examples/Rails/Rakefile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/examples/Rails/app/controllers/application_controller.rb b/examples/Rails/app/controllers/application_controller.rb new file mode 100644 index 0000000..13c271f --- /dev/null +++ b/examples/Rails/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::API +end diff --git a/examples/Rails/app/controllers/flags_controller.rb b/examples/Rails/app/controllers/flags_controller.rb new file mode 100644 index 0000000..ad5173a --- /dev/null +++ b/examples/Rails/app/controllers/flags_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class FlagsController < ApplicationController + def show + user = FeatBit::User.new( + params.fetch(:user_key, "rails-user"), + custom: { application: "rails", language: "ruby" } + ) + detail = featbit_client.variation_detail(params[:key], user, false) + + render json: { + key: params[:key], + value: detail.value, + variation_id: detail.variation_id, + reason: detail.reason, + error_kind: detail.error_kind, + error_message: detail.error_message + }.compact + end + + private + + def featbit_client + Rails.application.config.x.featbit.client + end +end diff --git a/examples/Rails/bin/rails b/examples/Rails/bin/rails new file mode 100755 index 0000000..22f2d8d --- /dev/null +++ b/examples/Rails/bin/rails @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/examples/Rails/config.ru b/examples/Rails/config.ru new file mode 100644 index 0000000..7e804ae --- /dev/null +++ b/examples/Rails/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +run Rails.application diff --git a/examples/Rails/config/application.rb b/examples/Rails/config/application.rb new file mode 100644 index 0000000..83167f5 --- /dev/null +++ b/examples/Rails/config/application.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "boot" +require "rails" +require "action_controller/railtie" + +Bundler.require(*Rails.groups) + +module FeatBitRailsExample + class Application < Rails::Application + config.load_defaults 7.2 + config.autoload_paths << Rails.root.join("lib") + config.api_only = true + end +end diff --git a/examples/Rails/config/boot.rb b/examples/Rails/config/boot.rb new file mode 100644 index 0000000..a29f4a4 --- /dev/null +++ b/examples/Rails/config/boot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" diff --git a/examples/Rails/config/environment.rb b/examples/Rails/config/environment.rb new file mode 100644 index 0000000..e8173e0 --- /dev/null +++ b/examples/Rails/config/environment.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "application" + +Rails.application.initialize! diff --git a/examples/Rails/config/environments/development.rb b/examples/Rails/config/environments/development.rb new file mode 100644 index 0000000..e925e6d --- /dev/null +++ b/examples/Rails/config/environments/development.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.enable_reloading = true + config.eager_load = false + config.consider_all_requests_local = true + config.server_timing = true +end diff --git a/examples/Rails/config/environments/production.rb b/examples/Rails/config/environments/production.rb new file mode 100644 index 0000000..3e02b10 --- /dev/null +++ b/examples/Rails/config/environments/production.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.enable_reloading = false + config.eager_load = true + config.consider_all_requests_local = false +end diff --git a/examples/Rails/config/environments/test.rb b/examples/Rails/config/environments/test.rb new file mode 100644 index 0000000..0b85560 --- /dev/null +++ b/examples/Rails/config/environments/test.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.enable_reloading = false + config.eager_load = false + config.consider_all_requests_local = true +end diff --git a/examples/Rails/config/initializers/featbit.rb b/examples/Rails/config/initializers/featbit.rb new file mode 100644 index 0000000..61bc670 --- /dev/null +++ b/examples/Rails/config/initializers/featbit.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "featbit" +require "featbit_client_registry" + +registry = FeatBitClientRegistry.new(env: ENV, logger: Rails.logger) +Rails.application.config.x.featbit = registry + +at_exit { registry.close } diff --git a/examples/Rails/config/routes.rb b/examples/Rails/config/routes.rb new file mode 100644 index 0000000..cf44f06 --- /dev/null +++ b/examples/Rails/config/routes.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + get "/flags/:key", to: "flags#show" +end diff --git a/examples/Rails/lib/featbit_client_registry.rb b/examples/Rails/lib/featbit_client_registry.rb new file mode 100644 index 0000000..96f9107 --- /dev/null +++ b/examples/Rails/lib/featbit_client_registry.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class FeatBitClientRegistry + def initialize(env:, logger:, client_factory: nil) + @env = env + @logger = logger + @client_factory = client_factory || ->(options) { FeatBit::Client.new(options) } + @mutex = Mutex.new + @client = nil + @closed = false + @close_result = nil + end + + def client + @mutex.synchronize do + raise "FeatBit client registry is closed" if @closed + + @client ||= @client_factory.call(build_options) + end + end + + def close + @mutex.synchronize do + return @close_result unless @close_result.nil? + + @closed = true + @close_result = @client.nil? || @client.close != false + end + rescue StandardError => e + @logger&.warn("FeatBit Rails shutdown failed: #{e.message}") + @mutex.synchronize { @close_result = false } + end + + private + + def build_options + 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: 10, + logger: @logger + ) + end +end diff --git a/featbit-server-sdk.gemspec b/featbit-server-sdk.gemspec new file mode 100644 index 0000000..1d9df3d --- /dev/null +++ b/featbit-server-sdk.gemspec @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "lib/featbit/version" + +Gem::Specification.new do |spec| + spec.name = "featbit-server-sdk" + spec.version = FeatBit::VERSION + spec.authors = ["FeatBit"] + spec.email = ["contact@featbit.co"] + spec.summary = "FeatBit Server-Side SDK for Ruby" + spec.description = "Thread-safe Ruby SDK for evaluating FeatBit feature flags locally." + spec.homepage = "https://github.com/featbit/featbit-ruby-server-sdk" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 3.1" + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + spec.metadata["documentation_uri"] = "#{spec.homepage}#readme" + spec.metadata["rubygems_mfa_required"] = "true" + spec.files = Dir.glob("{lib}/**/*") + %w[README.md LICENSE CHANGELOG.md] + spec.require_paths = ["lib"] + spec.add_dependency "logger", "~> 1.6" + spec.add_dependency "websocket-client-simple", "~> 0.9" +end diff --git a/lib/featbit.rb b/lib/featbit.rb new file mode 100644 index 0000000..c389d8e --- /dev/null +++ b/lib/featbit.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "featbit/version" +require_relative "featbit/options" +require_relative "featbit/user" +require_relative "featbit/evaluation_detail" +require_relative "featbit/status" +require_relative "featbit/data_store" +require_relative "featbit/evaluator" +require_relative "featbit/event_processor" +require_relative "featbit/web_socket_data_synchronizer" +require_relative "featbit/client" + +module FeatBit +end diff --git a/lib/featbit/client.rb b/lib/featbit/client.rb new file mode 100644 index 0000000..6f74e6e --- /dev/null +++ b/lib/featbit/client.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require "json" + +module FeatBit + class Client + attr_reader :options, :status_provider, :event_processor, :data_store + + def initialize(options = Options.new) + @options = options.is_a?(Options) ? options : Options.new(offline: true) + @data_store = @options.data_store || InMemoryDataStore.new + initial_status = @options.offline ? Status::OFFLINE : Status::STARTING + @status_provider = StatusProvider.new(initial_status, logger: @options.logger) + @listeners = {} + @listener_id = 0 + @listener_mutex = Mutex.new + @lifecycle_mutex = Mutex.new + @closed = false + @close_result = nil + + load_bootstrap(@options.bootstrap) if @options.bootstrap + @event_processor = build_event_processor + @evaluator = Evaluator.new( + flag_getter: ->(key) { @data_store.flag(key) }, + segment_getter: ->(id) { @data_store.segment(id) }, + logger: @options.logger + ) + start_synchronizer unless @options.offline + @status_provider.update(Status::READY) if @options.offline && @data_store.initialized? + @status_provider.wait_until_ready(@options.start_wait) if !@options.offline && @options.start_wait.positive? + rescue StandardError => e + safe_log(:error, "FeatBit client initialization failed: #{e.message}") + @status_provider ||= StatusProvider.new(Status::FAILED) + @status_provider.update(Status::FAILED, message: e.message) + @event_processor ||= NullEventProcessor.new + end + + def initialized? + @status_provider.ready? + rescue StandardError + false + end + + def variation(flag_key, user, default_value) + variation_detail(flag_key, user, default_value).value + rescue StandardError => e + safe_log(:error, "FeatBit variation failed: #{e.message}") + default_value + end + + def variation_detail(flag_key, user, default_value) + return safe_detail(flag_key, default_value, :client_not_ready) if @closed || !evaluable? + + normalized_user = normalize_user(user) + detail = @evaluator.evaluate(flag_key, normalized_user, default_value) + enqueue_evaluation(detail, normalized_user) if detail.success? + detail + rescue StandardError => e + safe_log(:error, "FeatBit variation detail failed: #{e.message}") + safe_detail(flag_key, default_value, :error, e.message) + end + + def bool_variation(flag_key, user, default_value = false) + typed_variation(flag_key, user, default_value, [TrueClass, FalseClass]) + end + + def string_variation(flag_key, user, default_value = "") + typed_variation(flag_key, user, default_value, [String]) + end + + def number_variation(flag_key, user, default_value = 0) + typed_variation(flag_key, user, default_value, [Numeric]) + end + + def json_variation(flag_key, user, default_value = {}) + typed_variation(flag_key, user, default_value, [Hash, Array]) + end + + def track(user, event_name, numeric_value = 1.0, properties: {}) + normalized_user = normalize_user(user) + return false unless normalized_user.valid? && !event_name.to_s.empty? + + @event_processor.enqueue( + user: normalized_user.to_h, + metrics: [{ + eventName: event_name.to_s, + numericValue: numeric_value.to_f, + route: "index/metric", + type: "CustomEvent", + appType: "rubyserverside", + timestamp: timestamp_ms, + properties: properties || {} + }] + ) + rescue StandardError => e + safe_log(:warn, "FeatBit track failed: #{e.message}") + false + end + + def flush + @event_processor.flush + rescue StandardError + false + end + + def add_flag_change_listener(callable = nil, &block) + listener = callable || block + return nil unless listener.respond_to?(:call) + + @listener_mutex.synchronize do + @listener_id += 1 + @listeners[@listener_id] = listener + @listener_id + end + rescue StandardError + nil + end + + def remove_flag_change_listener(id) + @listener_mutex.synchronize { !@listeners.delete(id).nil? } + rescue StandardError + false + end + + def close + @lifecycle_mutex.synchronize do + return @close_result unless @close_result.nil? + + @closed = true + results = [safe_close(@synchronizer), safe_close(@event_processor)] + results << safe_status_update(Status::CLOSED) + @close_result = results.all? + end + rescue StandardError => e + safe_log(:warn, "FeatBit client close failed: #{e.message}") + false + end + + alias stop close + + private + + def evaluable? + @data_store.initialized? && [Status::READY, Status::OFFLINE, Status::INTERRUPTED].include?(@status_provider.status) + end + + def load_bootstrap(bootstrap) + success = @data_store.init(bootstrap) + @status_provider.update(success ? Status::READY : Status::FAILED, message: success ? nil : "invalid bootstrap") + rescue StandardError => e + @status_provider.update(Status::FAILED, message: e.message) + end + + def build_event_processor + return NullEventProcessor.new if @options.offline || @options.disable_events + return @options.event_processor_factory.call(@options) if @options.event_processor_factory + + EventProcessor.new(@options) + rescue StandardError => e + safe_log(:warn, "FeatBit event processor unavailable: #{e.message}") + NullEventProcessor.new + end + + def start_synchronizer + unless @options.valid? + @status_provider.update(Status::FAILED, message: "invalid options") + return + end + + @synchronizer = if @options.synchronizer_factory + @options.synchronizer_factory.call(@options, @data_store, @status_provider, method(:broadcast_flag_change)) + else + WebSocketDataSynchronizer.new( + options: @options, + data_store: @data_store, + status_provider: @status_provider, + on_flags_changed: method(:broadcast_flag_change) + ) + end + @synchronizer.start + rescue StandardError => e + @status_provider.update(Status::FAILED, message: e.message) + end + + def typed_variation(flag_key, user, default_value, expected_types) + value = variation(flag_key, user, default_value) + expected_types.any? { |type| value.is_a?(type) } ? value : default_value + rescue StandardError + default_value + end + + def normalize_user(user) + return user if user.is_a?(User) + return User.new(user) if user.is_a?(String) + + if user.is_a?(Hash) + key = user["key"] || user[:key] || user["keyId"] || user[:keyId] || user["targeting_key"] || user[:targeting_key] + name = user["name"] || user[:name] + custom = user.reject { |candidate, _| %w[key keyId targeting_key name].include?(candidate.to_s) } + return User.new(key, name: name, custom: custom) + end + + User.new("") + rescue StandardError + User.new("") + end + + def enqueue_evaluation(detail, user) + @event_processor.enqueue( + user: user.to_h, + variations: [{ + featureFlagKey: detail.flag_key, + sendToExperiment: detail.send_to_experiment == true, + timestamp: timestamp_ms, + variation: { id: detail.variation_id, value: serialize_variation_value(detail.value), reason: detail.reason } + }] + ) + end + + def serialize_variation_value(value) + case value + when String then value + when TrueClass then "true" + when FalseClass then "false" + when Hash, Array then JSON.generate(value) + when nil then "" + else value.to_s + end + rescue StandardError + "" + end + + def broadcast_flag_change(flag_key) + listeners = @listener_mutex.synchronize { @listeners.values.dup } + listeners.each do |listener| + listener.call(flag_key) + rescue StandardError => e + safe_log(:warn, "FeatBit flag listener failed: #{e.message}") + end + true + rescue StandardError + false + end + + def safe_detail(flag_key, default_value, reason, message = nil) + EvaluationDetail.new( + value: default_value, + reason: Evaluator::REASONS.fetch(reason), + flag_key: flag_key, + error_kind: reason, + error_message: message + ) + end + + def safe_log(level, message) + @options&.logger&.public_send(level, message) + rescue StandardError + nil + end + + def safe_close(component) + return true unless component + + component.close != false + rescue StandardError => e + safe_log(:warn, "FeatBit component close failed: #{e.message}") + false + end + + def safe_status_update(status) + @status_provider.update(status) != false + rescue StandardError => e + safe_log(:warn, "FeatBit status update failed: #{e.message}") + false + end + + def timestamp_ms + (Time.now.to_f * 1000).round + end + end +end diff --git a/lib/featbit/data_store.rb b/lib/featbit/data_store.rb new file mode 100644 index 0000000..e9d97a1 --- /dev/null +++ b/lib/featbit/data_store.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "json" +require "time" + +module FeatBit + class InMemoryDataStore + def initialize + @flags = {}.freeze + @segments = {}.freeze + @flag_versions = {}.freeze + @segment_versions = {}.freeze + @version = 0 + @initialized = false + @mutex = Mutex.new + end + + def initialized? + @mutex.synchronize { @initialized } + rescue StandardError + false + end + + def version + @mutex.synchronize { @version } + rescue StandardError + 0 + end + + def flag(key) + item = @mutex.synchronize { @flags[key.to_s] } + item && !fetch(item, "isArchived", false) ? deep_dup(item) : nil + rescue StandardError + nil + end + + def segment(id) + item = @mutex.synchronize { @segments[id.to_s] } + item && !fetch(item, "isArchived", false) ? deep_dup(item) : nil + rescue StandardError + nil + end + + def all_flags + snapshot = @mutex.synchronize { @flags } + snapshot.each_with_object({}) do |(key, value), result| + result[key] = deep_dup(value) unless fetch(value, "isArchived", false) + end + rescue StandardError + {} + end + + def init(payload, version: nil) + data = normalize_payload(payload) + flags = Array(fetch(data, "featureFlags", [])).to_h do |flag| + [fetch(flag, "key").to_s, normalize_flag(flag)] + end + segments = Array(fetch(data, "segments", [])).to_h do |segment| + [fetch(segment, "id").to_s, normalize_hash(segment)] + end + incoming_version = version || derive_version(flags.values + segments.values) + incoming_version = 1 if incoming_version.to_i <= 0 + flag_versions = entity_versions(flags, version) + segment_versions = entity_versions(segments, version) + + @mutex.synchronize do + return false if @initialized && incoming_version.to_i <= @version + + @flags = deep_freeze(flags) + @segments = deep_freeze(segments) + @flag_versions = deep_freeze(flag_versions) + @segment_versions = deep_freeze(segment_versions) + @version = incoming_version.to_i + @initialized = true + end + true + rescue StandardError + false + end + + def upsert(kind, item, version: nil) + normalized = normalize_hash(item) + key = kind.to_sym == :flags ? fetch(normalized, "key") : fetch(normalized, "id") + return false if key.to_s.empty? + + incoming_version = version || item_version(normalized) + incoming_version = @mutex.synchronize { @version + 1 } if incoming_version.to_i <= 0 + @mutex.synchronize do + if kind.to_sym == :flags + return false if incoming_version.to_i <= @flag_versions.fetch(key.to_s, 0) + + copy = @flags.dup + version_copy = @flag_versions.dup + copy[key.to_s] = normalize_flag(normalized) + version_copy[key.to_s] = incoming_version.to_i + @flags = deep_freeze(copy) + @flag_versions = deep_freeze(version_copy) + else + return false if incoming_version.to_i <= @segment_versions.fetch(key.to_s, 0) + + copy = @segments.dup + version_copy = @segment_versions.dup + copy[key.to_s] = normalized + version_copy[key.to_s] = incoming_version.to_i + @segments = deep_freeze(copy) + @segment_versions = deep_freeze(version_copy) + end + @version = [@version, incoming_version.to_i].max + @initialized = true + end + true + rescue StandardError + false + end + + private + + def normalize_payload(payload) + parsed = payload.is_a?(String) ? JSON.parse(payload) : payload + parsed = fetch(parsed, "data", parsed) + normalize_hash(parsed || {}) + end + + def normalize_flag(flag) + normalized = normalize_hash(flag) + variations = Array(fetch(normalized, "variations", [])) + normalized["variationMap"] = variations.to_h do |variation| + [fetch(variation, "id").to_s, fetch(variation, "value")] + end + normalized + end + + def normalize_hash(value) + case value + when Hash + value.each_with_object({}) { |(key, item), result| result[key.to_s] = normalize_hash(item) } + when Array + value.map { |item| normalize_hash(item) } + else + value + end + end + + def derive_version(items) + items.map { |item| item_version(item) }.max.to_i + end + + def entity_versions(items, snapshot_version) + items.transform_values do |item| + next snapshot_version.to_i if snapshot_version + + version = item_version(item) + version.positive? ? version : 1 + end + end + + def item_version(item) + explicit = fetch(item, "timestamp") + return explicit.to_i if explicit + + updated = fetch(item, "updatedAt") + updated ? (Time.parse(updated.to_s).to_f * 1000).to_i : 0 + rescue StandardError + 0 + end + + def fetch(hash, key, default = nil) + hash.is_a?(Hash) ? hash.fetch(key.to_s, hash.fetch(key.to_sym, default)) : default + end + + def deep_dup(value) + Marshal.load(Marshal.dump(value)) + end + + def deep_freeze(value) + case value + when Hash + value.each do |key, item| + deep_freeze(key) + deep_freeze(item) + end + when Array + value.each { |item| deep_freeze(item) } + end + value.freeze + end + end +end diff --git a/lib/featbit/evaluation_detail.rb b/lib/featbit/evaluation_detail.rb new file mode 100644 index 0000000..c390f93 --- /dev/null +++ b/lib/featbit/evaluation_detail.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FeatBit + EvaluationDetail = Struct.new( + :value, :reason, :variation_id, :flag_key, :flag_name, :send_to_experiment, :error_kind, :error_message, + keyword_init: true + ) do + def success? + error_kind.nil? + end + end +end diff --git a/lib/featbit/evaluator.rb b/lib/featbit/evaluator.rb new file mode 100644 index 0000000..b9fb464 --- /dev/null +++ b/lib/featbit/evaluator.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "digest/md5" +require "json" +require "set" + +module FeatBit + class Evaluator + REASONS = { + client_not_ready: "client not ready", + flag_not_found: "flag not found", + error: "error in evaluation", + user_not_specified: "user not specified", + wrong_type: "wrong type", + flag_off: "flag off", + target_match: "target match", + rule_match: "rule match", + fallthrough: "fall through all rules" + }.freeze + + def initialize(flag_getter:, segment_getter:, logger: nil) + @flag_getter = flag_getter + @segment_getter = segment_getter + @logger = logger + end + + def evaluate(flag_key, user, default_value) + return error_detail(flag_key, default_value, :user_not_specified) unless user&.valid? + + flag = @flag_getter.call(flag_key.to_s) + return error_detail(flag_key, default_value, :flag_not_found) unless flag + + variation_id, reason, send_to_experiment = select_variation(flag, user, Set.new) + raise "no variation matched" if variation_id.to_s.empty? + + raw_value = fetch(fetch(flag, "variationMap", {}), variation_id) + raise "variation value not found" if raw_value.nil? + + value = cast(raw_value, fetch(flag, "variationType")) + EvaluationDetail.new( + value: value, + reason: REASONS.fetch(reason), + variation_id: variation_id, + flag_key: fetch(flag, "key", flag_key), + flag_name: fetch(flag, "name"), + send_to_experiment: send_to_experiment + ) + rescue StandardError => e + @logger&.error("FeatBit evaluation failed: #{e.message}") + error_detail(flag_key, default_value, :error, e.message) + end + + private + + def select_variation(flag, user, visited_segments) + return [fetch(flag, "disabledVariationId"), :flag_off, false] unless fetch(flag, "isEnabled", false) + + Array(fetch(flag, "targetUsers", [])).each do |target| + if Array(fetch(target, "keyIds", [])).map(&:to_s).include?(user.key) + return [fetch(target, "variationId"), :target_match, fetch(flag, "exptIncludeAllTargets", false) == true] + end + end + + Array(fetch(flag, "rules", [])).each do |rule| + next unless Array(fetch(rule, "conditions", [])).all? do |condition| + condition_matches?(user, condition, visited_segments) + end + + variation_id, send_to_experiment = rollout_variation(flag, rule, user) + return [variation_id, :rule_match, send_to_experiment] if variation_id + end + + variation_id, send_to_experiment = rollout_variation(flag, fetch(flag, "fallthrough", {}), user) + [variation_id, :fallthrough, send_to_experiment] + end + + def rollout_variation(flag, rollout, user) + dispatch_key = fetch(rollout, "dispatchKey") || "keyId" + dispatch_value = user[dispatch_key].to_s + bucket_key = "#{fetch(flag, 'key')}#{dispatch_value}" + Array(fetch(rollout, "variations", [])).each do |variation| + range = Array(fetch(variation, "rollout", [])) + next unless in_bucket?(bucket_key, range) + + return [fetch(variation, "id"), send_to_experiment?(flag, rollout, variation, bucket_key, range)] + end + [nil, false] + end + + def send_to_experiment?(flag, rollout, variation, bucket_key, range) + return true if fetch(flag, "exptIncludeAllTargets", false) + return false unless fetch(rollout, "includedInExpt", false) + + span = range[1].to_f - range[0].to_f + experiment_rollout = fetch(variation, "exptRollout", 0).to_f + return false unless span.positive? && experiment_rollout.positive? + + upper_bound = [experiment_rollout / span, 1.0].min + in_bucket?("expt#{bucket_key}", [0, upper_bound]) + rescue StandardError + false + end + + def in_bucket?(key, range) + return false unless range.length == 2 + return true if range[0].to_f.zero? && range[1].to_f >= 1.0 + + signed = Digest::MD5.digest(key.to_s).byteslice(0, 4).unpack1("l<") + percentage = (signed / -2_147_483_648.0).abs + percentage >= range[0].to_f && percentage < range[1].to_f + rescue StandardError + false + end + + def condition_matches?(user, condition, visited_segments = Set.new) + property = fetch(condition, "property") + operation = fetch(condition, "op") || property + actual = user[property] + expected = fetch(condition, "value") + actual_number = numeric_value(actual) + expected_number = numeric_value(expected) + + case operation + when "BiggerEqualThan" then actual_number && expected_number && actual_number >= expected_number + when "BiggerThan" then actual_number && expected_number && actual_number > expected_number + when "LessEqualThan" then actual_number && expected_number && actual_number <= expected_number + when "LessThan" then actual_number && expected_number && actual_number < expected_number + when "Equal" then !actual.nil? && !expected.nil? && actual.to_s == expected.to_s + when "NotEqual" then actual.nil? || expected.nil? || actual.to_s != expected.to_s + when "Contains" then !actual.nil? && !expected.nil? && actual.to_s.include?(expected.to_s) + when "NotContain" then actual.nil? || expected.nil? || !actual.to_s.include?(expected.to_s) + when "IsOneOf" then json_array(expected).map(&:to_s).include?(actual.to_s) + when "NotOneOf" then !json_array(expected).map(&:to_s).include?(actual.to_s) + when "StartsWith" then !actual.nil? && actual.to_s.start_with?(expected.to_s) + when "EndsWith" then !actual.nil? && actual.to_s.end_with?(expected.to_s) + when "IsTrue" then actual == true || actual.to_s.casecmp("true").zero? + when "IsFalse" then actual == false || actual.to_s.casecmp("false").zero? + when "MatchRegex" then !actual.nil? && Regexp.new(expected.to_s).match?(actual.to_s) + when "NotMatchRegex" then actual.nil? || !Regexp.new(expected.to_s).match?(actual.to_s) + when "User is in segment" then segment_match?(user, expected, visited_segments) + when "User is not in segment" then !segment_match?(user, expected, visited_segments) + else false + end + rescue StandardError + false + end + + def segment_match?(user, serialized_ids, visited_segments) + json_array(serialized_ids).any? do |segment_id| + normalized_id = segment_id.to_s + next false if visited_segments.include?(normalized_id) + + segment = @segment_getter.call(normalized_id) + next false unless segment + next false if Array(fetch(segment, "excluded", [])).map(&:to_s).include?(user.key) + next true if Array(fetch(segment, "included", [])).map(&:to_s).include?(user.key) + + nested_visited = visited_segments | [normalized_id] + Array(fetch(segment, "rules", [])).any? do |rule| + Array(fetch(rule, "conditions", [])).all? do |condition| + condition_matches?(user, condition, nested_visited) + end + end + end + end + + def cast(value, type) + case type.to_s.downcase + when "boolean" then cast_boolean(value) + when "number" then numeric_cast(value) + when "json" then value.is_a?(String) ? JSON.parse(value) : value + else value.to_s + end + end + + def cast_boolean(value) + return value if [true, false].include?(value) + return true if value.is_a?(String) && value.casecmp("true").zero? + return false if value.is_a?(String) && value.casecmp("false").zero? + + raise "invalid boolean variation" + end + + def numeric_cast(value) + number = Float(value) + number.finite? && number == number.to_i ? number.to_i : number + end + + def numeric_value(value) + Float(value).round(5) + rescue StandardError + nil + end + + def json_array(value) + parsed = value.is_a?(String) ? JSON.parse(value) : value + parsed.is_a?(Array) ? parsed : [] + rescue StandardError + [] + end + + def error_detail(flag_key, default_value, kind, message = nil) + EvaluationDetail.new( + value: default_value, + reason: REASONS.fetch(kind), + flag_key: flag_key, + error_kind: kind, + error_message: message + ) + end + + def fetch(hash, key, default = nil) + hash.is_a?(Hash) ? hash.fetch(key.to_s, hash.fetch(key.to_sym, default)) : default + end + end +end diff --git a/lib/featbit/event_processor.rb b/lib/featbit/event_processor.rb new file mode 100644 index 0000000..a37eff9 --- /dev/null +++ b/lib/featbit/event_processor.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "timeout" +require "uri" + +module FeatBit + class EventProcessor + class DeliveryRejected < StandardError; end + + FLUSH = "__flush__" + STOP = "__stop__" + CONTROL_TIMEOUT = 5.0 + + def initialize(options, sender: nil) + @options = options + @sender = sender || method(:post_batch) + @queue = SizedQueue.new(options.events_capacity) + @control_queue = Queue.new + @dropped_events = 0 + @drop_mutex = Mutex.new + @state_mutex = Mutex.new + @close_mutex = Mutex.new + @closed = false + @close_result = nil + @thread = Thread.new { run } + @thread.name = "featbit-event-processor" if @thread.respond_to?(:name=) + rescue StandardError => e + options.logger&.error("FeatBit event processor failed to start: #{e.message}") + @closed = true + end + + def enqueue(event) + return false if event.nil? + + @state_mutex.synchronize do + return false if @closed + + @queue.push(event, true) + true + end + rescue ThreadError + @drop_mutex.synchronize { @dropped_events += 1 } + false + rescue StandardError => e + @options.logger&.warn("FeatBit event enqueue failed: #{e.message}") + false + end + + def flush + return false if closed? + + ack = Queue.new + @control_queue << { type: FLUSH, ack: ack } + + Timeout.timeout(CONTROL_TIMEOUT) { ack.pop } != false + rescue StandardError => e + @options.logger&.warn("FeatBit event flush failed: #{e.message}") + false + end + + def close + @close_mutex.synchronize do + return @close_result unless @close_result.nil? + + mark_closed + ack = Queue.new + @control_queue << { type: STOP, ack: ack } + delivered = Timeout.timeout(CONTROL_TIMEOUT) { ack.pop } != false + @thread&.join(CONTROL_TIMEOUT) + @close_result = delivered && !@thread&.alive? + end + rescue StandardError => e + @options.logger&.warn("FeatBit event processor close failed: #{e.message}") + false + end + + def dropped_events + @drop_mutex.synchronize { @dropped_events } + rescue StandardError + 0 + end + + private + + def run + batch = [] + deadline = monotonic_time + @options.events_flush_interval + loop do + event = pop_with_timeout([deadline - monotonic_time, 0].max) + if control?(event, STOP) + drain_events(batch) + delivered = send_batch(batch) + batch.clear + event[:ack].push(delivered) + break + elsif control?(event, FLUSH) + drain_events(batch) + delivered = send_batch(batch) + batch.clear + event[:ack].push(delivered) + deadline = monotonic_time + @options.events_flush_interval + elsif event + batch << event + if batch.length >= @options.events_batch_size + send_batch(batch) + batch.clear + deadline = monotonic_time + @options.events_flush_interval + end + elsif monotonic_time >= deadline + send_batch(batch) + batch.clear + deadline = monotonic_time + @options.events_flush_interval + end + end + rescue StandardError => e + @options.logger&.error("FeatBit event processor stopped unexpectedly: #{e.message}") + ensure + mark_closed + end + + def pop_with_timeout(timeout) + deadline = monotonic_time + timeout + loop do + return @control_queue.pop(true) + rescue ThreadError + begin + return @queue.pop(true) + rescue ThreadError + return nil if monotonic_time >= deadline + + sleep([deadline - monotonic_time, 0.01].min) + end + end + end + + def drain_events(batch) + loop do + batch << @queue.pop(true) + rescue ThreadError + break + end + batch + end + + def control?(event, type) + event.is_a?(Hash) && event[:type] == type + end + + def send_batch(batch) + return true if batch.empty? + + batch.each_slice(@options.events_batch_size).all? { |slice| send_slice(slice) } + end + + def send_slice(batch) + attempts = 0 + + begin + attempts += 1 + delivered = @sender.call(batch.dup) + raise "sender rejected batch" if delivered == false + + true + rescue DeliveryRejected + raise + rescue StandardError + retry if attempts < 3 + + raise + end + rescue StandardError => e + @options.logger&.warn("FeatBit event delivery failed: #{e.message}") + false + end + + def post_batch(batch) + uri = URI.parse(@options.events_uri) + request = Net::HTTP::Post.new(uri) + request["Authorization"] = @options.env_secret + request["User-Agent"] = "featbit-ruby-server-sdk/#{FeatBit::VERSION}" + request["Content-Type"] = "application/json" + request.body = JSON.generate(batch) + response = Net::HTTP.start( + uri.host, uri.port, + use_ssl: uri.scheme == "https", + open_timeout: @options.connect_timeout, + read_timeout: @options.read_timeout + ) { |http| http.request(request) } + unless response.is_a?(Net::HTTPSuccess) + message = response.body.to_s.gsub(/\s+/, " ").strip[0, 200] + raise DeliveryRejected, ["HTTP #{response.code}", message].reject(&:empty?).join(": ") + end + + true + end + + def closed? + @state_mutex.synchronize { @closed } + rescue StandardError + true + end + + def mark_closed + @state_mutex.synchronize { @closed = true } + end + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + end + + class NullEventProcessor + def enqueue(_event) = false + def flush = true + def close = true + def dropped_events = 0 + end +end diff --git a/lib/featbit/options.rb b/lib/featbit/options.rb new file mode 100644 index 0000000..473c5e2 --- /dev/null +++ b/lib/featbit/options.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "logger" +require "uri" + +module FeatBit + class Options + DEFAULT_STREAMING_URL = "wss://app-eval.featbit.co" + DEFAULT_EVENT_URL = "https://app-eval.featbit.co" + + attr_reader :env_secret, :streaming_url, :event_url, :start_wait, + :offline, :bootstrap, :disable_events, :logger, + :events_capacity, :events_flush_interval, :events_batch_size, + :connect_timeout, :read_timeout, :reconnect_delay, + :data_store, :synchronizer_factory, :event_processor_factory + + def initialize(env_secret: "", streaming_url: DEFAULT_STREAMING_URL, + event_url: DEFAULT_EVENT_URL, start_wait: 5.0, offline: false, + bootstrap: nil, disable_events: false, logger: nil, + events_capacity: 10_000, events_flush_interval: 1.0, + events_batch_size: 50, connect_timeout: 5.0, + read_timeout: 10.0, reconnect_delay: 1.0, + data_store: nil, synchronizer_factory: nil, + event_processor_factory: nil) + @env_secret = env_secret.to_s + @streaming_url = normalize_url(streaming_url) + @event_url = normalize_url(event_url) + @start_wait = positive_number(start_wait, 5.0) + @offline = offline == true + @bootstrap = bootstrap + @disable_events = disable_events == true + @logger = logger || Logger.new($stderr, level: Logger::WARN) + @events_capacity = positive_integer(events_capacity, 10_000) + @events_flush_interval = positive_number(events_flush_interval, 1.0) + @events_batch_size = positive_integer(events_batch_size, 50) + @connect_timeout = positive_number(connect_timeout, 5.0) + @read_timeout = positive_number(read_timeout, 10.0) + @reconnect_delay = positive_number(reconnect_delay, 1.0) + @data_store = data_store + @synchronizer_factory = synchronizer_factory + @event_processor_factory = event_processor_factory + freeze + rescue StandardError => e + warn("FeatBit options error: #{e.message}") + @env_secret = "" + @streaming_url = DEFAULT_STREAMING_URL + @event_url = DEFAULT_EVENT_URL + @start_wait = 5.0 + @offline = true + @bootstrap = nil + @disable_events = true + @logger = Logger.new($stderr, level: Logger::WARN) + @events_capacity = 10_000 + @events_flush_interval = 1.0 + @events_batch_size = 50 + @connect_timeout = 5.0 + @read_timeout = 10.0 + @reconnect_delay = 1.0 + freeze + end + + def streaming_uri + append_path(streaming_url, "/streaming") + end + + def events_uri + append_path(event_url, "/api/public/insight/track") + end + + def valid? + return true if offline + + !env_secret.empty? && valid_uri?(streaming_uri, %w[ws wss]) && valid_uri?(events_uri, %w[http https]) + rescue StandardError + false + end + + private + + def normalize_url(value) + value.to_s.sub(%r{/+\z}, "") + end + + def append_path(base, path) + base.end_with?(path) ? base : "#{base}#{path}" + end + + def valid_uri?(value, schemes) + uri = URI.parse(value) + schemes.include?(uri.scheme) && !uri.host.to_s.empty? + end + + def positive_number(value, fallback) + number = Float(value) + number.positive? ? number : fallback + rescue StandardError + fallback + end + + def positive_integer(value, fallback) + number = Integer(value) + number.positive? ? number : fallback + rescue StandardError + fallback + end + end +end diff --git a/lib/featbit/status.rb b/lib/featbit/status.rb new file mode 100644 index 0000000..d972291 --- /dev/null +++ b/lib/featbit/status.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module FeatBit + module Status + STARTING = :starting + READY = :ready + INTERRUPTED = :interrupted + OFFLINE = :offline + FAILED = :failed + CLOSED = :closed + end + + class StatusProvider + def initialize(initial = Status::STARTING, logger: nil) + @status = initial + @message = nil + @listeners = {} + @next_listener_id = 0 + @mutex = Mutex.new + @condition = ConditionVariable.new + @logger = logger + end + + def status + @mutex.synchronize { @status } + end + + def message + @mutex.synchronize { @message } + end + + def ready? + status == Status::READY + end + + def wait_until_ready(timeout = 5.0) + deadline = monotonic_time + timeout.to_f + @mutex.synchronize do + until @status == Status::READY + return false if [Status::FAILED, Status::CLOSED].include?(@status) + + remaining = deadline - monotonic_time + return false unless remaining.positive? + + @condition.wait(@mutex, remaining) + end + end + true + rescue StandardError + false + end + + def add_listener(callable = nil, &block) + listener = callable || block + return nil unless listener.respond_to?(:call) + + @mutex.synchronize do + @next_listener_id += 1 + @listeners[@next_listener_id] = listener + @next_listener_id + end + rescue StandardError + nil + end + + def remove_listener(id) + @mutex.synchronize { !@listeners.delete(id).nil? } + rescue StandardError + false + end + + def update(new_status, message: nil) + listeners = @mutex.synchronize do + changed = @status != new_status || @message != message + @status = new_status + @message = message + @condition.broadcast + changed ? @listeners.values.dup : [] + end + listeners.each do |listener| + listener.call(new_status, message) + rescue StandardError => e + @logger&.warn("FeatBit status listener failed: #{e.message}") + end + true + rescue StandardError => e + @logger&.warn("FeatBit status update failed: #{e.message}") + false + end + + private + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + end +end diff --git a/lib/featbit/user.rb b/lib/featbit/user.rb new file mode 100644 index 0000000..84a243b --- /dev/null +++ b/lib/featbit/user.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module FeatBit + class User + attr_reader :key, :name, :custom + + def initialize(key, name: nil, custom: {}) + @key = key.to_s + @name = name + @custom = deep_freeze(deep_copy(custom || {}).transform_keys(&:to_s)) + freeze + rescue StandardError + @key = "" + @name = nil + @custom = {}.freeze + freeze + end + + def valid? + !key.empty? + end + + def [](attribute) + normalized = attribute.to_s.downcase + return key if %w[key keyid targeting_key].include?(normalized) + return name if normalized == "name" + + custom[attribute.to_s] || custom[normalized] + rescue StandardError + nil + end + + def to_h + { + "keyId" => key, + "name" => name, + "customizedProperties" => custom.map do |property_name, value| + { "name" => property_name, "value" => value.to_s } + end + } + end + + private + + def deep_copy(value) + case value + when Hash then value.each_with_object({}) { |(key, item), copy| copy[key.to_s] = deep_copy(item) } + when Array then value.map { |item| deep_copy(item) } + when String then value.dup + else value + end + end + + def deep_freeze(value) + case value + when Hash + value.each do |key, item| + deep_freeze(key) + deep_freeze(item) + end + when Array then value.each { |item| deep_freeze(item) } + end + value.freeze + end + end +end diff --git a/lib/featbit/version.rb b/lib/featbit/version.rb new file mode 100644 index 0000000..21516c6 --- /dev/null +++ b/lib/featbit/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module FeatBit + VERSION = "0.1.0" +end diff --git a/lib/featbit/web_socket_data_synchronizer.rb b/lib/featbit/web_socket_data_synchronizer.rb new file mode 100644 index 0000000..3ce0ca3 --- /dev/null +++ b/lib/featbit/web_socket_data_synchronizer.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require "json" +require "securerandom" +require "time" +require "timeout" +require "websocket-client-simple" + +module FeatBit + class WebSocketDataSynchronizer + ALPHABETS = { "0" => "Q", "1" => "B", "2" => "W", "3" => "S", "4" => "P", + "5" => "H", "6" => "D", "7" => "X", "8" => "Z", "9" => "U" }.freeze + PING_INTERVAL = 10.0 + + def initialize(options:, data_store:, status_provider:, on_flags_changed: nil, connector: nil) + @options = options + @data_store = data_store + @status_provider = status_provider + @on_flags_changed = on_flags_changed + @connector = connector || method(:connect) + @closed = false + @socket_mutex = Mutex.new + @lifecycle_mutex = Mutex.new + @thread = nil + @close_result = nil + end + + def start + @lifecycle_mutex.synchronize do + return false if @closed || @thread&.alive? + + @thread = Thread.new { run } + @thread.name = "featbit-websocket-sync" if @thread.respond_to?(:name=) + true + end + rescue StandardError => e + fail_status(e) + false + end + + def close + @lifecycle_mutex.synchronize do + return @close_result unless @close_result.nil? + + @closed = true + socket = @socket_mutex.synchronize { @socket } + socket_closed = safe_close_socket(socket) + @thread&.join(5) unless Thread.current.equal?(@thread) + @close_result = socket_closed && !@thread&.alive? + end + rescue StandardError => e + @options.logger&.warn("FeatBit synchronizer close failed: #{e.message}") + false + end + + def process_message(message) + envelope = message.is_a?(String) ? JSON.parse(message) : message + return false unless fetch(envelope, "messageType") == "data-sync" + + data = fetch(envelope, "data", {}) + event_type = fetch(data, "eventType") + return false unless valid_data?(data, event_type) + + old_keys = @data_store.all_flags.keys + if event_type == "full" + changed = @data_store.init(data) + changed_keys = old_keys | @data_store.all_flags.keys + else + changed, changed_keys = process_patch(data) + end + return false unless changed + + changed_keys.each { |key| safely_notify(key) } + @status_provider.update(Status::READY) + true + rescue JSON::ParserError => e + @status_provider.update(Status::FAILED, message: "invalid data: #{e.message}") + false + rescue StandardError => e + fail_status(e) + false + end + + private + + def run + delay = @options.reconnect_delay + until @closed + socket = nil + begin + socket = @connector.call(websocket_url, headers) + @socket_mutex.synchronize { @socket = socket } + break if @closed + + configure_socket(socket) + delay = @options.reconnect_delay + next_ping = monotonic_time + PING_INTERVAL + until @closed || socket_closed?(socket) + if monotonic_time >= next_ping + socket.send(JSON.generate(messageType: "ping", data: nil)) + next_ping = monotonic_time + PING_INTERVAL + end + interruptible_sleep(0.05) + end + break if @closed + + @status_provider.update(Status::INTERRUPTED, message: "WebSocket disconnected") + interruptible_sleep(delay) + delay = [delay * 2, 30.0].min + rescue StandardError => e + fail_status(e, interrupted: true) + interruptible_sleep(delay) unless @closed + delay = [delay * 2, 30.0].min + ensure + safe_close_socket(socket) if @closed + @socket_mutex.synchronize { @socket = nil } + end + end + end + + def connect(url, request_headers) + Timeout.timeout(@options.connect_timeout) do + WebSocket::Client::Simple.connect(url, headers: request_headers) + end + end + + def configure_socket(socket) + open_handler = method(:handle_socket_open) + message_handler = method(:handle_socket_message) + error_handler = method(:handle_socket_error) + close_handler = method(:handle_socket_close) + + socket.on(:open) { open_handler.call(socket) } + socket.on(:message) { |event| message_handler.call(event) } + socket.on(:error) { |event| error_handler.call(socket, event) } + socket.on(:close) { |event| close_handler.call(event) } + end + + def handle_socket_open(socket) + socket.send(JSON.generate(messageType: "data-sync", data: { timestamp: @data_store.version })) + rescue StandardError => e + fail_status(e) + end + + def handle_socket_message(event) + process_message(event.respond_to?(:data) ? event.data : event.to_s) + end + + def handle_socket_error(socket, event) + return if @closed + + fail_status(event.respond_to?(:message) ? event.message : event, interrupted: true) + safe_close_socket(socket) + end + + def handle_socket_close(_event) + @status_provider.update(Status::INTERRUPTED, message: "WebSocket closed") unless @closed + end + + def socket_closed?(socket) + return socket.closed? if socket.respond_to?(:closed?) + return !socket.open? if socket.respond_to?(:open?) + + false + rescue StandardError + true + end + + def safe_close_socket(socket) + return true unless socket + + socket.close != false + rescue StandardError => e + @options.logger&.warn("FeatBit WebSocket close failed: #{e.message}") + false + end + + def interruptible_sleep(duration) + deadline = monotonic_time + duration.to_f + until @closed + remaining = deadline - monotonic_time + break unless remaining.positive? + + sleep([remaining, 0.05].min) + end + end + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def process_patch(data) + items = Array(fetch(data, "featureFlags", [])).map { |flag| [:flags, flag] } + items.concat(Array(fetch(data, "segments", [])).map { |segment| [:segments, segment] }) + changed_keys = [] + results = items.sort_by { |_kind, item| item_version(item) }.map do |kind, item| + applied = @data_store.upsert(kind, item, version: item_version(item)) + if applied && kind == :flags + changed_keys << fetch(item, "key").to_s + elsif applied + changed_keys.concat(flags_referencing_segment(fetch(item, "id").to_s)) + end + applied + end + [results.all?, changed_keys.uniq] + end + + def valid_data?(data, event_type) + data.is_a?(Hash) && + %w[full patch].include?(event_type) && + fetch(data, "featureFlags").is_a?(Array) && + fetch(data, "segments").is_a?(Array) + end + + def flags_referencing_segment(segment_id) + @data_store.all_flags.each_with_object([]) do |(flag_key, flag), result| + referenced = Array(fetch(flag, "rules", [])).any? do |rule| + Array(fetch(rule, "conditions", [])).any? do |condition| + next false unless fetch(condition, "op").nil? + + serialized = fetch(condition, "value", "[]") + ids = serialized.is_a?(String) ? JSON.parse(serialized) : serialized + Array(ids).map(&:to_s).include?(segment_id) + rescue JSON::ParserError + false + end + end + result << flag_key if referenced + end + end + + def item_version(item) + explicit = fetch(item, "timestamp") + return explicit.to_i if explicit + + updated = fetch(item, "updatedAt") + updated ? (Time.parse(updated.to_s).to_f * 1000).to_i : @data_store.version + 1 + rescue StandardError + @data_store.version + 1 + end + + def websocket_url + "#{@options.streaming_uri}?token=#{build_token(@options.env_secret)}&type=server" + end + + def headers + { + "Authorization" => @options.env_secret, + "User-Agent" => "featbit-ruby-server-sdk/#{FeatBit::VERSION}", + "Content-Type" => "application/json" + } + end + + def build_token(secret) + text = secret.to_s.delete_suffix("=") + timestamp = (Time.now.to_f * 1000).round.to_s + timestamp_code = encode_number(timestamp, timestamp.length) + start = [SecureRandom.random_number([text.length, 1].max), 2].max + start = text.length if start > text.length + "#{encode_number(start, 3)}#{encode_number(timestamp_code.length, 2)}#{text[0, start]}#{timestamp_code}#{text[start..]}" + rescue StandardError + secret.to_s + end + + def encode_number(number, length) + padded = number.to_s.rjust([12, length].max, "0") + padded[-length, length].chars.map { |character| ALPHABETS.fetch(character) }.join + end + + def safely_notify(flag_key) + @on_flags_changed&.call(flag_key) + rescue StandardError => e + @options.logger&.warn("FeatBit flag listener failed: #{e.message}") + end + + def fail_status(error, interrupted: false) + message = error.respond_to?(:message) ? error.message : error.to_s + @options.logger&.warn("FeatBit WebSocket synchronization failed: #{message}") + @status_provider.update(interrupted ? Status::INTERRUPTED : Status::FAILED, message: message) + end + + def fetch(hash, key, default = nil) + hash.is_a?(Hash) ? hash.fetch(key.to_s, hash.fetch(key.to_sym, default)) : default + end + end +end diff --git a/scripts/live_integration_check.rb b/scripts/live_integration_check.rb new file mode 100644 index 0000000..beeba7a --- /dev/null +++ b/scripts/live_integration_check.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "json" +require "timeout" +require "featbit" + +secret = ENV.fetch("FEATBIT_ENV_SECRET") +flag_key = ENV.fetch("FEATBIT_FLAG_KEY") +options = FeatBit::Options.new( + env_secret: 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: 15, + connect_timeout: 10, + read_timeout: 15 +) + +baseline_thread_ids = Thread.list.map(&:object_id) +client = FeatBit::Client.new(options) +raise "FeatBit client did not become ready: #{client.status_provider.message}" unless client.initialized? + +user = FeatBit::User.new( + ENV.fetch("FEATBIT_USER_KEY", "ruby-live-review-user"), + name: "Ruby SDK Live Review", + custom: { sdk: "ruby", check: "live-integration" } +) +detail = client.variation_detail(flag_key, user, false) +raise "live flag evaluation failed: #{detail.error_message}" unless detail.success? + +errors = Queue.new +thread_count = Integer(ENV.fetch("FEATBIT_LIVE_THREADS", "8"), 10) +evaluations_per_thread = Integer(ENV.fetch("FEATBIT_LIVE_EVALUATIONS_PER_THREAD", "5"), 10) +evaluation_count = thread_count * evaluations_per_thread +thread_count.times.map do |index| + Thread.new do + thread_user = FeatBit::User.new("ruby-live-review-#{index}") + evaluations_per_thread.times do + value = client.bool_variation(flag_key, thread_user, false) + errors << "non-boolean value" unless [true, false].include?(value) + rescue StandardError => e + errors << e.message + end + end +end.each(&:join) +raise "concurrent live evaluations failed: #{errors.size}" unless errors.empty? + +track_result = client.track(user, "ruby_sdk_live_review", 1.0) +flush_result = client.flush +ready_status = client.status_provider.status +close_result = client.close +raise "remote track was not accepted by the local event queue" unless track_result +raise "remote event flush failed" unless flush_result +raise "client did not close cleanly" unless close_result + +Timeout.timeout(5) do + loop do + residual = Thread.list.reject { |thread| baseline_thread_ids.include?(thread.object_id) } + .select { |thread| thread.name&.start_with?("featbit-") && thread.alive? } + break if residual.empty? + + sleep(0.05) + end +end + +puts JSON.generate( + initialized: true, + synchronized_flag: flag_key, + value: detail.value, + variation_id: detail.variation_id, + reason: detail.reason, + concurrent_evaluations: evaluation_count, + status_before_close: ready_status, + track: track_result, + flush: flush_result, + close: close_result, + residual_worker_threads: 0 +) diff --git a/scripts/resource_audit.rb b/scripts/resource_audit.rb new file mode 100644 index 0000000..3415de4 --- /dev/null +++ b/scripts/resource_audit.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "json" +require "featbit" + +def bootstrap + variation = { "id" => "on", "value" => "true" } + { + "messageType" => "data-sync", + "data" => { + "eventType" => "full", + "segments" => [], + "featureFlags" => [{ + "id" => "audit-flag", + "key" => "resource-audit", + "name" => "Resource Audit", + "variationType" => "boolean", + "variations" => [variation], + "targetUsers" => [], + "rules" => [], + "isEnabled" => true, + "disabledVariationId" => "on", + "fallthrough" => { "variations" => [{ "id" => "on", "rollout" => [0, 1] }] }, + "isArchived" => false, + "updatedAt" => "2026-01-01T00:00:00Z" + }] + } + } +end + +def named_worker_threads + Thread.list.select { |thread| thread.name&.start_with?("featbit-") && thread.alive? } +end + +baseline_thread_ids = named_worker_threads.map(&:object_id) +options = FeatBit::Options.new(env_secret: "audit", events_flush_interval: 60) + +100.times do + processor = FeatBit::EventProcessor.new(options, sender: ->(_batch) { true }) + raise "event processor did not close" unless processor.close +end + +client = FeatBit::Client.new(FeatBit::Options.new(offline: true, bootstrap: bootstrap)) +user = FeatBit::User.new("resource-audit-user", custom: { cohort: "audit" }) +errors = Queue.new +evaluation_count = 16 * 5_000 + +5_000.times { client.bool_variation("resource-audit", user, false) } +GC.start(full_mark: true, immediate_sweep: true) +baseline_heap_slots = GC.stat(:heap_live_slots) + +16.times.map do + Thread.new do + 5_000.times do + value = client.bool_variation("resource-audit", user, false) + errors << "wrong value" unless value == true + rescue StandardError => e + errors << e + end + end +end.each(&:join) + +client.close +GC.start(full_mark: true, immediate_sweep: true) +final_heap_slots = GC.stat(:heap_live_slots) +retained_heap_slots = final_heap_slots - baseline_heap_slots +allowed_retained_slots = [10_000, (baseline_heap_slots * 0.2).ceil].max + +leaked_threads = named_worker_threads.reject { |thread| baseline_thread_ids.include?(thread.object_id) } +raise "concurrent evaluation errors: #{errors.size}" unless errors.empty? +raise "worker threads leaked: #{leaked_threads.map(&:name).join(', ')}" unless leaked_threads.empty? +raise "possible heap retention: #{retained_heap_slots} slots" if retained_heap_slots > allowed_retained_slots + +puts JSON.generate( + evaluations: evaluation_count, + concurrent_errors: errors.size, + leaked_worker_threads: leaked_threads.size, + baseline_heap_slots: baseline_heap_slots, + final_heap_slots: final_heap_slots, + retained_heap_slots: retained_heap_slots, + allowed_retained_slots: allowed_retained_slots, + result: "ok" +) diff --git a/spec/client_spec.rb b/spec/client_spec.rb new file mode 100644 index 0000000..a7c0b7c --- /dev/null +++ b/spec/client_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FeatBit::Client do + it "evaluates offline flags, exposes details and tracks no events offline" do + client = described_class.new(FeatBit::Options.new(offline: true, bootstrap: test_bootstrap(test_flag(type: "boolean")))) + expect(client).to be_initialized + expect(client.bool_variation("welcome", { key: "u1" }, false)).to be(true) + detail = client.variation_detail("welcome", { key: "u1" }, false) + expect(detail.variation_id).to eq("on") + expect(client.track({ key: "u1" }, "clicked")).to be(false) + expect(client.close).to be(true) + end + + it "serializes event users with the FeatBit wire format" do + user = FeatBit::User.new("u1", name: "Ada", custom: { country: "cn", score: 7 }) + + expect(user.to_h).to eq( + "keyId" => "u1", + "name" => "Ada", + "customizedProperties" => [ + { "name" => "country", "value" => "cn" }, + { "name" => "score", "value" => "7" } + ] + ) + end + + it "keeps typed evaluation values but serializes event variation values as strings" do + events = [] + processor = Object.new + processor.define_singleton_method(:enqueue) do |event| + events << event + true + end + processor.define_singleton_method(:close) { true } + synchronizer = instance_double("Synchronizer", start: true, close: true) + options = FeatBit::Options.new( + env_secret: "secret", + bootstrap: test_bootstrap(test_flag(type: "boolean")), + start_wait: 0.001, + synchronizer_factory: ->(*) { synchronizer }, + event_processor_factory: ->(*) { processor } + ) + client = described_class.new(options) + + expect(client.bool_variation("welcome", { key: "u1" }, false)).to be(true) + expect(events.dig(0, :variations, 0, :variation, :value)).to eq("true") + expect(client.close).to be(true) + end + + it "defensively copies and freezes nested user attributes" do + attributes = { profile: { tags: ["beta"] } } + user = FeatBit::User.new("u1", custom: attributes) + attributes[:profile][:tags] << "mutated" + + expect(user["profile"]).to eq("tags" => ["beta"]) + expect(user["profile"]).to be_frozen + expect(user["profile"]["tags"]).to be_frozen + end + + it "never raises from public evaluation methods" do + broken_store = Object.new + def broken_store.initialized? = true + def broken_store.flag(_key) = raise("boom") + def broken_store.segment(_key) = raise("boom") + options = FeatBit::Options.new(offline: true, data_store: broken_store) + client = described_class.new(options) + expect { client.variation("x", Object.new, "fallback") }.not_to raise_error + expect(client.variation("x", Object.new, "fallback")).to eq("fallback") + end + + it "allows listener mutation while notifications run" do + client = described_class.new(FeatBit::Options.new(offline: true, bootstrap: test_bootstrap(test_flag))) + received = Queue.new + id = client.add_flag_change_listener { |key| received << key } + threads = 10.times.map do + Thread.new do + 50.times do + transient = client.add_flag_change_listener { |_key| nil } + client.remove_flag_change_listener(transient) + end + end + end + client.send(:broadcast_flag_change, "welcome") + threads.each(&:join) + expect(received.pop).to eq("welcome") + expect(client.remove_flag_change_listener(id)).to be(true) + end + + it "starts online components when offline and disable_events are false" do + synchronizer = instance_double("Synchronizer", start: true, close: true) + processor = instance_double("EventProcessor", close: true) + options = FeatBit::Options.new( + env_secret: "secret", + start_wait: 0.001, + offline: false, + disable_events: false, + synchronizer_factory: ->(*) { synchronizer }, + event_processor_factory: ->(*) { processor } + ) + + client = described_class.new(options) + + expect(options.offline).to be(false) + expect(options.disable_events).to be(false) + expect(synchronizer).to have_received(:start) + expect(client.event_processor).to be(processor) + expect(client.close).to be(true) + end + + it "attempts to close every component once and never raises" do + synchronizer = instance_double("Synchronizer", start: true) + processor = instance_double("EventProcessor") + allow(synchronizer).to receive(:close).and_raise("synchronizer failed") + allow(processor).to receive(:close).and_return(true) + options = FeatBit::Options.new( + env_secret: "secret", + start_wait: 0.001, + synchronizer_factory: ->(*) { synchronizer }, + event_processor_factory: ->(*) { processor } + ) + client = described_class.new(options) + + expect(client.close).to be(false) + expect(client.close).to be(false) + expect(synchronizer).to have_received(:close).once + expect(processor).to have_received(:close).once + expect(client.status_provider.status).to eq(FeatBit::Status::CLOSED) + end +end diff --git a/spec/data_store_spec.rb b/spec/data_store_spec.rb new file mode 100644 index 0000000..2285f4f --- /dev/null +++ b/spec/data_store_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FeatBit::InMemoryDataStore do + it "initializes immutable snapshots and returns defensive copies" do + store = described_class.new + expect(store.init(test_bootstrap(test_flag))).to be(true) + first = store.flag("welcome") + first["name"] = "changed" + expect(store.flag("welcome")["name"]).to eq("welcome") + end + + it "accepts only newer patch versions" do + store = described_class.new + store.init(test_bootstrap(test_flag), version: 10) + expect(store.upsert(:flags, test_flag, version: 9)).to be(false) + expect(store.upsert(:flags, test_flag, version: 11)).to be(true) + end + + it "accepts equal timestamps for different entities" do + store = described_class.new + store.init(test_bootstrap(test_flag), version: 10) + + expect(store.upsert(:flags, test_flag(key: "first"), version: 11)).to be(true) + expect(store.upsert(:flags, test_flag(key: "second"), version: 11)).to be(true) + expect(store.all_flags.keys).to include("first", "second") + end + + it "is safe under concurrent readers and writers" do + store = described_class.new + store.init(test_bootstrap(test_flag), version: 1) + errors = Queue.new + threads = 8.times.map do |index| + Thread.new do + 200.times do |iteration| + store.flag("welcome") + store.all_flags + store.upsert(:flags, test_flag(key: "flag-#{index}"), version: 2 + (index * 200) + iteration) + rescue StandardError => e + errors << e + end + end + end + threads.each(&:join) + expect(errors).to be_empty + end +end diff --git a/spec/evaluator_spec.rb b/spec/evaluator_spec.rb new file mode 100644 index 0000000..c701651 --- /dev/null +++ b/spec/evaluator_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FeatBit::Evaluator do + let(:store) { FeatBit::InMemoryDataStore.new } + let(:evaluator) do + described_class.new(flag_getter: ->(key) { store.flag(key) }, segment_getter: ->(id) { store.segment(id) }) + end + + it "returns the disabled variation and ID" do + store.init(test_bootstrap(test_flag(enabled: false))) + detail = evaluator.evaluate("welcome", FeatBit::User.new("u1"), "fallback") + expect(detail.value).to eq("goodbye") + expect(detail.variation_id).to eq("off") + expect(detail.reason).to eq("flag off") + expect(detail.send_to_experiment).to be(false) + end + + it "matches explicit targets before rules" do + flag = test_flag(targets: [{ "keyIds" => ["u1"], "variationId" => "off" }]) + flag["exptIncludeAllTargets"] = true + store.init(test_bootstrap(flag)) + detail = evaluator.evaluate("welcome", FeatBit::User.new("u1"), "fallback") + expect(detail.value).to eq("goodbye") + expect(detail.reason).to eq("target match") + expect(detail.send_to_experiment).to be(true) + end + + it "supports attributes, rules, segments and typed JSON" do + segment = { + "id" => "beta", "included" => [], "excluded" => [], "isArchived" => false, + "updatedAt" => "2026-01-01T00:00:00Z", + "rules" => [{ "conditions" => [{ "property" => "country", "op" => "Equal", "value" => "cn" }] }] + } + variations = [{ "id" => "on", "value" => '{"layout":"new"}' }, { "id" => "off", "value" => "{}" }] + rules = [{ + "conditions" => [{ "property" => "User is in segment", "op" => nil, "value" => '["beta"]' }], + "variations" => [{ "id" => "on", "rollout" => [0, 1] }] + }] + store.init(test_bootstrap(test_flag(type: "json", variations: variations, rules: rules), segments: [segment])) + detail = evaluator.evaluate("welcome", FeatBit::User.new("u1", custom: { country: "cn" }), {}) + expect(detail.value).to eq("layout" => "new") + expect(detail.reason).to eq("rule match") + end + + it "returns safe error details for missing flags and users" do + store.init(test_bootstrap(test_flag)) + expect(evaluator.evaluate("missing", FeatBit::User.new("u1"), "fallback").error_kind).to eq(:flag_not_found) + expect(evaluator.evaluate("welcome", FeatBit::User.new(""), "fallback").error_kind).to eq(:user_not_specified) + end + + it "rejects malformed typed variations instead of coercing them" do + malformed = test_flag(type: "boolean", variations: [{ "id" => "on", "value" => "not-a-boolean" }]) + malformed["disabledVariationId"] = "on" + store.init(test_bootstrap(malformed)) + detail = evaluator.evaluate("welcome", FeatBit::User.new("u1"), false) + expect(detail.value).to be(false) + expect(detail.error_kind).to eq(:error) + end + + it "terminates safely when segments reference each other" do + segment_condition = lambda do |segment_id| + { "property" => "User is in segment", "op" => nil, "value" => JSON.generate([segment_id]) } + end + segments = { + "a" => { "id" => "a", "included" => [], "excluded" => [], "rules" => [{ "conditions" => [segment_condition.call("b")] }] }, + "b" => { "id" => "b", "included" => [], "excluded" => [], "rules" => [{ "conditions" => [segment_condition.call("a")] }] } + } + flag = test_flag( + rules: [{ "conditions" => [segment_condition.call("a")], "variations" => [{ "id" => "off", "rollout" => [0, 1] }] }] + ) + store.init(test_bootstrap(flag, segments: segments.values)) + + detail = evaluator.evaluate("welcome", FeatBit::User.new("u1"), "fallback") + + expect(detail.value).to eq("hello") + expect(detail.reason).to eq(FeatBit::Evaluator::REASONS[:fallthrough]) + end +end diff --git a/spec/event_processor_spec.rb b/spec/event_processor_spec.rb new file mode 100644 index 0000000..3183470 --- /dev/null +++ b/spec/event_processor_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "spec_helper" +require "socket" +require "timeout" + +RSpec.describe FeatBit::EventProcessor do + it "batches and flushes events on its worker" do + batches = Queue.new + options = FeatBit::Options.new(env_secret: "secret", events_flush_interval: 60, events_batch_size: 2) + processor = described_class.new(options, sender: ->(batch) { batches << batch }) + processor.enqueue(id: 1) + processor.enqueue(id: 2) + expect(batches.pop.map { |event| event[:id] }).to eq([1, 2]) + expect(processor.close).to be(true) + end + + it "drops instead of blocking when the bounded queue is full" do + options = FeatBit::Options.new(env_secret: "secret", events_capacity: 1, events_flush_interval: 60) + processor = described_class.allocate + processor.instance_variable_set(:@options, options) + processor.instance_variable_set(:@queue, SizedQueue.new(1)) + processor.instance_variable_set(:@control_queue, Queue.new) + processor.instance_variable_set(:@dropped_events, 0) + processor.instance_variable_set(:@drop_mutex, Mutex.new) + processor.instance_variable_set(:@state_mutex, Mutex.new) + processor.instance_variable_set(:@close_mutex, Mutex.new) + processor.instance_variable_set(:@closed, false) + processor.instance_variable_set(:@close_result, nil) + expect(processor.enqueue(id: 1)).to be(true) + expect(processor.enqueue(id: 2)).to be(false) + expect(processor.dropped_events).to eq(1) + end + + it "posts FeatBit events with authentication and JSON payload" do + server = TCPServer.new("127.0.0.1", 0) + received = Queue.new + server_thread = Thread.new do + socket = server.accept + request_line = socket.gets + headers = {} + while (line = socket.gets) && line != "\r\n" + key, value = line.split(":", 2) + headers[key.downcase] = value.strip + end + body = socket.read(headers.fetch("content-length").to_i) + received << [request_line, headers, body] + socket.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + socket.close + end + + options = FeatBit::Options.new( + env_secret: "secret", + event_url: "http://127.0.0.1:#{server.local_address.ip_port}", + events_batch_size: 1 + ) + processor = described_class.new(options) + expect(processor.enqueue(id: 1)).to be(true) + request_line, headers, body = Timeout.timeout(3) { received.pop } + expect(request_line).to start_with("POST /api/public/insight/track") + expect(headers["authorization"]).to eq("secret") + expect(JSON.parse(body)).to eq([{ "id" => 1 }]) + expect(processor.close).to be(true) + ensure + server&.close + server_thread&.join(1) + end + + it "reports delivery failure from an explicit flush" do + attempts = 0 + options = FeatBit::Options.new(env_secret: "secret", events_flush_interval: 60) + processor = described_class.new(options, sender: lambda { |_batch| + attempts += 1 + false + }) + expect(processor.enqueue(id: 1)).to be(true) + expect(processor.flush).to be(false) + expect(attempts).to eq(3) + expect(processor.close).to be(true) + end + + it "partitions events drained by flush to the configured server batch size" do + batches = [] + options = FeatBit::Options.new(env_secret: "secret", events_flush_interval: 60, events_batch_size: 2) + processor = described_class.new(options, sender: lambda { |batch| + batches << batch + true + }) + 5.times { |id| processor.enqueue(id: id) } + + expect(processor.flush).to be(true) + expect(batches.flatten.map { |event| event[:id] }).to contain_exactly(0, 1, 2, 3, 4) + expect(batches.map(&:length)).to all(be <= 2) + expect(processor.close).to be(true) + end + + it "closes without leaking a worker when the event queue is full" do + first_delivery = Queue.new + release = Queue.new + calls = 0 + sender = lambda do |_batch| + calls += 1 + if calls == 1 + first_delivery << true + release.pop + end + true + end + options = FeatBit::Options.new(env_secret: "secret", events_capacity: 1, events_batch_size: 1) + processor = described_class.new(options, sender: sender) + expect(processor.enqueue(id: 1)).to be(true) + Timeout.timeout(2) { first_delivery.pop } + expect(processor.enqueue(id: 2)).to be(true) + close_thread = Thread.new { processor.close } + release << true + + expect(Timeout.timeout(2) { close_thread.value }).to be(true) + expect(calls).to eq(2) + end + + it "makes concurrent close idempotent" do + options = FeatBit::Options.new(env_secret: "secret") + processor = described_class.new(options, sender: ->(_batch) { true }) + results = 10.times.map { Thread.new { processor.close } }.map(&:value) + + expect(results).to all(be(true)) + end + + it "does not retain worker threads across repeated lifecycles" do + existing_ids = Thread.list.filter_map { |thread| thread.object_id if thread.name == "featbit-event-processor" } + options = FeatBit::Options.new(env_secret: "secret") + 20.times do + processor = described_class.new(options, sender: ->(_batch) { true }) + expect(processor.close).to be(true) + end + + leaked = Thread.list.select do |thread| + thread.name == "featbit-event-processor" && !existing_ids.include?(thread.object_id) && thread.alive? + end + expect(leaked).to be_empty + end +end diff --git a/spec/examples_spec.rb b/spec/examples_spec.rb new file mode 100644 index 0000000..92b8316 --- /dev/null +++ b/spec/examples_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../examples/Rails/lib/featbit_client_registry" + +RSpec.describe "repository examples" do + it "keeps the Rails client process-scoped, lazy, and thread-safe" do + logger = Logger.new(nil) + fake_client = instance_double(FeatBit::Client, close: true) + creations = Queue.new + registry = FeatBitClientRegistry.new( + env: { "FEATBIT_ENV_SECRET" => "secret" }, + logger: logger, + client_factory: lambda { |_options| + creations << true + fake_client + } + ) + + clients = 20.times.map { Thread.new { registry.client } }.map(&:value) + + expect(clients).to all(be(fake_client)) + expect(creations.size).to eq(1) + expect(registry.close).to be(true) + expect(registry.close).to be(true) + expect(fake_client).to have_received(:close).once + expect { registry.client }.to raise_error("FeatBit client registry is closed") + end + + it "keeps every committed example and audit script syntactically valid" do + root = File.expand_path("..", __dir__) + files = Dir.glob(File.join(root, "{examples,scripts}", "**", "*.rb")) + + expect(files).not_to be_empty + files.each do |file| + expect(RubyVM::InstructionSequence.compile_file(file)).to be_a(RubyVM::InstructionSequence) + end + end +end diff --git a/spec/options_spec.rb b/spec/options_spec.rb new file mode 100644 index 0000000..8113b3a --- /dev/null +++ b/spec/options_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FeatBit::Options do + it "builds FeatBit protocol endpoints" do + options = described_class.new(env_secret: "secret", streaming_url: "wss://example.test/", event_url: "https://example.test/") + expect(options.streaming_uri).to eq("wss://example.test/streaming") + expect(options.events_uri).to eq("https://example.test/api/public/insight/track") + expect(options).to be_valid + end + + it "does not raise for invalid user configuration" do + expect { described_class.new(start_wait: Object.new) }.not_to raise_error + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..5af543b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "featbit" + +RSpec.configure do |config| + config.disable_monkey_patching! + config.order = :random +end + +def test_flag(key: "welcome", type: "string", enabled: true, variations: nil, targets: [], rules: [], fallthrough: nil) + variations ||= [{ "id" => "on", "value" => type == "boolean" ? "true" : "hello" }, + { "id" => "off", "value" => type == "boolean" ? "false" : "goodbye" }] + fallthrough ||= { + "dispatchKey" => "keyId", + "variations" => [{ "id" => variations.first["id"], "rollout" => [0, 1] }] + } + { + "id" => "flag-#{key}", "key" => key, "name" => key, "variationType" => type, + "variations" => variations, "targetUsers" => targets, "rules" => rules, + "isEnabled" => enabled, "disabledVariationId" => variations.last["id"], + "fallthrough" => fallthrough, "isArchived" => false, + "updatedAt" => "2026-01-01T00:00:00Z" + } +end + +def test_bootstrap(*flags, segments: []) + { + "messageType" => "data-sync", + "data" => { "eventType" => "full", "featureFlags" => flags, "segments" => segments } + } +end diff --git a/spec/web_socket_data_synchronizer_spec.rb b/spec/web_socket_data_synchronizer_spec.rb new file mode 100644 index 0000000..6bf173c --- /dev/null +++ b/spec/web_socket_data_synchronizer_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "spec_helper" +require "timeout" + +RSpec.describe FeatBit::WebSocketDataSynchronizer do + let(:options) { FeatBit::Options.new(env_secret: "secret") } + let(:store) { FeatBit::InMemoryDataStore.new } + let(:status) { FeatBit::StatusProvider.new(logger: options.logger) } + + it "processes full synchronization messages and reports ready" do + changes = [] + synchronizer = described_class.new(options: options, data_store: store, status_provider: status, on_flags_changed: lambda { |key| + changes << key + }) + expect(synchronizer.process_message(test_bootstrap(test_flag))).to be(true) + expect(store.flag("welcome")).not_to be_nil + expect(status.status).to eq(FeatBit::Status::READY) + expect(changes).to include("welcome") + end + + it "rejects malformed data without raising" do + synchronizer = described_class.new(options: options, data_store: store, status_provider: status) + expect { synchronizer.process_message("not-json") }.not_to raise_error + expect(status.status).to eq(FeatBit::Status::FAILED) + end + + it "applies patches in timestamp order and only reports affected flags" do + other = test_flag(key: "other") + expect(store.init(test_bootstrap(test_flag, other), version: 1)).to be(true) + changes = [] + synchronizer = described_class.new( + options: options, + data_store: store, + status_provider: status, + on_flags_changed: ->(key) { changes << key } + ) + patched = test_flag + patched["name"] = "updated" + patched["updatedAt"] = "2026-01-02T00:00:00Z" + message = { + "messageType" => "data-sync", + "data" => { "eventType" => "patch", "featureFlags" => [patched], "segments" => [] } + } + expect(synchronizer.process_message(message)).to be(true) + expect(store.flag("welcome")["name"]).to eq("updated") + expect(changes).to eq(["welcome"]) + end + + it "connects with FeatBit authentication and requests the local version" do + fake_socket = Class.new do + attr_reader :handlers, :sent + + def initialize + @handlers = {} + @sent = [] + @closed = false + end + + def on(event, &block) + @handlers[event] = block + end + + def send(message) + @sent << message + end + + def emit(event, payload = nil) + instance_exec(payload, &@handlers.fetch(event)) + end + + def closed? + @closed + end + + def close + @closed = true + end + end.new + connection = Queue.new + connector = lambda do |url, headers| + connection << [url, headers] + fake_socket + end + synchronizer = described_class.new( + options: options, + data_store: store, + status_provider: status, + connector: connector + ) + expect(synchronizer.start).to be(true) + url, headers = Timeout.timeout(2) { connection.pop } + Timeout.timeout(2) { sleep(0.01) until fake_socket.handlers.key?(:open) } + fake_socket.emit(:open) + expect(url).to match(%r{\Awss://app-eval\.featbit\.co/streaming\?token=.+&type=server\z}) + expect(headers["Authorization"]).to eq("secret") + expect(JSON.parse(fake_socket.sent.fetch(0))).to eq("messageType" => "data-sync", "data" => { "timestamp" => 0 }) + expect(synchronizer.close).to be(true) + end + + it "interrupts reconnect backoff promptly when closed" do + attempted = Queue.new + connector = lambda do |_url, _headers| + attempted << true + raise "offline" + end + slow_options = FeatBit::Options.new(env_secret: "secret", reconnect_delay: 10) + synchronizer = described_class.new( + options: slow_options, + data_store: store, + status_provider: status, + connector: connector + ) + expect(synchronizer.start).to be(true) + Timeout.timeout(2) { attempted.pop } + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + expect(synchronizer.close).to be(true) + expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 1 + end + + it "closes a failed socket so the reconnect loop can recover" do + fake_socket = Class.new do + attr_reader :handlers + + def initialize + @handlers = {} + @closed = false + end + + def on(event, &block) = @handlers[event] = block + def closed? = @closed + def close = @closed = true + end.new + connected = Queue.new + synchronizer = described_class.new( + options: options, + data_store: store, + status_provider: status, + connector: lambda { |*| + connected << true + fake_socket + } + ) + synchronizer.start + Timeout.timeout(2) { connected.pop } + Timeout.timeout(2) { sleep(0.01) until fake_socket.handlers.key?(:error) } + + fake_socket.handlers.fetch(:error).call(StandardError.new("connection failed")) + + expect(fake_socket).to be_closed + expect(synchronizer.close).to be(true) + end + + it "encodes a current timestamp into the connection token without falling back to the secret" do + secret = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=" + synchronizer = described_class.new(options: options, data_store: store, status_provider: status) + token = synchronizer.send(:build_token, secret) + reverse_alphabet = described_class::ALPHABETS.invert + decode = lambda do |encoded| + Integer(encoded.chars.map { |character| reverse_alphabet.fetch(character) }.join, 10) + end + + start = decode.call(token[0, 3]) + timestamp_length = decode.call(token[3, 2]) + body = token[5..] + timestamp = decode.call(body[start, timestamp_length]) + reconstructed_secret = body[0, start] + body[(start + timestamp_length)..] + + expect(token).not_to eq(secret) + expect(reconstructed_secret).to eq(secret.delete_suffix("=")) + expect((Time.now.to_f * 1000).round - timestamp).to be_between(0, 2_000) + end +end