setbit 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9b8ccfb4587b58fc04c79c6557dafd02f05914742c58bd352e2e3786065b0446
4
+ data.tar.gz: cbee38616ee38877db945334e4ec36d2cd8a2c790880a52655600b37a8c523a9
5
+ SHA512:
6
+ metadata.gz: aefc7d300599e61ac61e4591b6111eabc913fa9253d18f6d2f6d1681a99f6b638929d82ac166da9ef1749960c399accecd917ace25336895e72156374ebcd41b
7
+ data.tar.gz: 0bb36dac20e88bd51226a386755cffec66358b4a4f86deac5daa4275c1ab9fb750693263785cd8a75463f2f63742070eba4da7a7c7b70f9d0c48d9555d61b1c5
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to the SetBit Ruby SDK will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-11-23
9
+
10
+ ### Added
11
+ - Initial release of SetBit Ruby SDK
12
+ - Boolean flag checking with `enabled?` method
13
+ - A/B test variant selection with `variant` method
14
+ - Conversion tracking with `track` method
15
+ - Tag-based targeting support
16
+ - Manual flag refresh with `refresh` method
17
+ - Comprehensive error handling with custom exceptions
18
+ - Fail-open design - returns defaults on errors
19
+ - Full test coverage with RSpec
20
+ - Rails and Sinatra integration examples
21
+ - Zero external runtime dependencies
22
+
23
+ ### Features
24
+ - Boolean flag checking with consistent behavior
25
+ - Rollout flags with percentage-based gradual rollout (requires user_id)
26
+ - Weighted random distribution for A/B tests
27
+ - In-memory flag caching
28
+ - Silent failure for tracking (logs errors but doesn't crash)
29
+ - Support for custom base URL (self-hosted instances)
30
+ - Consistent user assignment using SHA-256 hashing
31
+ - Idiomatic Ruby API with proper naming conventions
32
+ - Thread-safe operations
33
+
34
+ [0.1.0]: https://github.com/setbit/setbit-ruby/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SetBit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,577 @@
1
+ # SetBit Ruby SDK
2
+
3
+ Official Ruby SDK for [SetBit](https://setbit.io) - Simple feature flags and A/B testing.
4
+
5
+ ## Features
6
+
7
+ - โœ… **Boolean Flags** - Simple on/off feature toggles
8
+ - ๐Ÿงช **A/B Testing** - Weighted variant distribution for experiments
9
+ - ๐Ÿ“Š **Conversion Tracking** - Track events and conversions
10
+ - ๐Ÿท๏ธ **Tag-Based Targeting** - Target by environment, app, team, region, etc.
11
+ - ๐Ÿš€ **Fail-Open Design** - Returns defaults if API is unreachable
12
+ - ๐Ÿชถ **Zero Dependencies** - Uses only Ruby standard library
13
+ - ๐Ÿ’Ž **Idiomatic Ruby** - Follows Ruby conventions and best practices
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem 'setbit'
21
+ ```
22
+
23
+ And then execute:
24
+
25
+ ```bash
26
+ bundle install
27
+ ```
28
+
29
+ Or install it yourself as:
30
+
31
+ ```bash
32
+ gem install setbit
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ```ruby
38
+ require 'setbit'
39
+
40
+ # Initialize the client
41
+ client = SetBit::Client.new(
42
+ api_key: "pk_your_api_key_here",
43
+ tags: { env: "production", app: "web" }
44
+ )
45
+
46
+ # Check a boolean flag (user_id required for analytics)
47
+ user_id = get_current_user_id
48
+ if client.enabled?("new-checkout", user_id: user_id)
49
+ show_new_checkout
50
+ else
51
+ show_old_checkout
52
+ end
53
+
54
+ # Rollout flag (gradual percentage-based rollout)
55
+ variant = client.variant("new-api", user_id: current_user.id)
56
+ if variant == "enabled"
57
+ use_new_api
58
+ else
59
+ use_old_api
60
+ end
61
+
62
+ # Get A/B test variant
63
+ variant = client.variant("pricing-experiment", user_id: current_user.id)
64
+ case variant
65
+ when "variant_a"
66
+ show_price_99
67
+ when "variant_b"
68
+ show_price_149
69
+ else # control
70
+ show_price_129
71
+ end
72
+
73
+ # Track conversions
74
+ client.track("purchase", user_id: current_user.id, metadata: { amount: 99.99 })
75
+ ```
76
+
77
+ ## API Reference
78
+
79
+ ### Initialization
80
+
81
+ ```ruby
82
+ SetBit::Client.new(api_key:, tags: {}, base_url: "https://flags.setbit.io", logger: nil)
83
+ ```
84
+
85
+ **Parameters:**
86
+ - `api_key` (String, required): Your SetBit API key
87
+ - `tags` (Hash, optional): Tags for targeting flags (e.g., `{ env: "production", app: "web" }`)
88
+ - `base_url` (String, optional): API endpoint URL (useful for self-hosted instances)
89
+ - `logger` (Logger, optional): Custom logger instance
90
+
91
+ **Raises:**
92
+ - `SetBit::AuthError`: If API key is invalid
93
+ - `SetBit::APIError`: If initial flag fetch fails
94
+
95
+ **Example:**
96
+ ```ruby
97
+ client = SetBit::Client.new(
98
+ api_key: "pk_abc123",
99
+ tags: { env: "production", app: "web", region: "us-east" }
100
+ )
101
+ ```
102
+
103
+ ---
104
+
105
+ ### `enabled?(flag_name, user_id:, default: false)`
106
+
107
+ Check if a flag is enabled. Returns `true` if the flag is globally enabled, `false` otherwise.
108
+
109
+ **Parameters:**
110
+ - `flag_name` (String): Name of the flag
111
+ - `user_id` (String, **required**): User identifier (required for analytics and billing)
112
+ - `default` (Boolean): Value to return if flag not found (default: `false`)
113
+
114
+ **Returns:** `Boolean` - `true` if enabled, `false` otherwise
115
+
116
+ **Note:** This method returns whether the flag is globally enabled. For rollout flags, use `variant()` to check which rollout group the user is in.
117
+
118
+ **Example:**
119
+ ```ruby
120
+ # Check if flag is enabled (user_id required)
121
+ user_id = get_current_user_id
122
+ if client.enabled?("new-dashboard", user_id: user_id)
123
+ render_new_dashboard
124
+ else
125
+ render_old_dashboard
126
+ end
127
+
128
+ # With custom default
129
+ if client.enabled?("beta-feature", user_id: user_id, default: true)
130
+ show_beta_feature
131
+ end
132
+ ```
133
+
134
+ ---
135
+
136
+ ### `variant(flag_name, user_id:, default: "control")`
137
+
138
+ Get the variant for an A/B test experiment or rollout flag.
139
+
140
+ **Parameters:**
141
+ - `flag_name` (String): Name of the experiment or rollout flag
142
+ - `user_id` (String): User identifier (required)
143
+ - `default` (String): Variant to return if flag not found (default: `"control"`)
144
+
145
+ **Returns:** `String` - Variant name
146
+
147
+ For experiments: `"control"`, `"variant_a"`, `"variant_b"`, etc.
148
+ For rollout flags: `"enabled"` (user in rollout) or `"disabled"` (user not in rollout)
149
+
150
+ **Example:**
151
+ ```ruby
152
+ # A/B test experiment
153
+ variant = client.variant("button-color-test", user_id: current_user.id)
154
+
155
+ button_color = case variant
156
+ when "variant_a" then "blue"
157
+ when "variant_b" then "green"
158
+ else "red" # control
159
+ end
160
+
161
+ # Rollout flag (gradual percentage-based rollout)
162
+ variant = client.variant("new-api", user_id: current_user.id)
163
+
164
+ if variant == "enabled"
165
+ use_new_api # User is in the rollout group
166
+ else
167
+ use_old_api # User is not in the rollout group
168
+ end
169
+ ```
170
+
171
+ ---
172
+
173
+ ### `track(event_name, user_id:, flag_name: nil, variant: nil, metadata: {})`
174
+
175
+ Track a conversion event.
176
+
177
+ **Parameters:**
178
+ - `event_name` (String): Name of the event (e.g., `"purchase"`, `"signup"`)
179
+ - `user_id` (String, **required**): User identifier (required for analytics and billing)
180
+ - `flag_name` (String, optional): Flag to associate with this conversion
181
+ - `variant` (String, optional): Variant the user was assigned to (for A/B test attribution)
182
+ - `metadata` (Hash, optional): Additional event data
183
+
184
+ **Returns:** `void`
185
+
186
+ **Note:** This method fails silently - errors are logged but not raised.
187
+
188
+ **Example:**
189
+ ```ruby
190
+ user_id = current_user.id
191
+
192
+ # Track conversion with variant attribution (recommended for A/B tests)
193
+ variant = client.variant("pricing-test", user_id: user_id)
194
+ # ... later when user converts ...
195
+ client.track(
196
+ "purchase",
197
+ user_id: user_id,
198
+ flag_name: "pricing-test",
199
+ variant: variant,
200
+ metadata: { amount: 99.99, currency: "USD" }
201
+ )
202
+
203
+ # Track basic conversion
204
+ client.track("signup", user_id: user_id)
205
+
206
+ # Track with flag association only
207
+ client.track("purchase", user_id: user_id, flag_name: "checkout-experiment")
208
+ ```
209
+
210
+ ---
211
+
212
+ ### `refresh`
213
+
214
+ Manually refresh flags from the API.
215
+
216
+ **Returns:** `void`
217
+
218
+ **Raises:**
219
+ - `SetBit::AuthError`: If API key is invalid
220
+ - `SetBit::APIError`: If API request fails
221
+
222
+ **Example:**
223
+ ```ruby
224
+ # Refresh flags if you know they've changed
225
+ client.refresh
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Usage Examples
231
+
232
+ ### Boolean Flags
233
+
234
+ ```ruby
235
+ require 'setbit'
236
+
237
+ client = SetBit::Client.new(
238
+ api_key: "pk_abc123",
239
+ tags: { env: "production", app: "web" }
240
+ )
241
+
242
+ # Simple feature toggle (boolean flag)
243
+ enable_dark_mode if client.enabled?("dark-mode")
244
+
245
+ # With default value
246
+ debug_mode = client.enabled?("debug-logging", default: false)
247
+ ```
248
+
249
+ ### Rollout Flags
250
+
251
+ ```ruby
252
+ # Gradual percentage-based rollout
253
+ # Use variant() to check which rollout group the user is in
254
+ variant = client.variant("new-api-v2", user_id: current_user.id)
255
+
256
+ if variant == "enabled"
257
+ use_api_v2 # User is in the rollout group
258
+ else
259
+ use_api_v1 # User is not in the rollout group
260
+ end
261
+
262
+ # Example: Rollout new checkout flow
263
+ checkout_variant = client.variant("new-checkout", user_id: current_user.id)
264
+
265
+ if checkout_variant == "enabled"
266
+ render_new_checkout
267
+ client.track("checkout_started", user_id: current_user.id, flag_name: "new-checkout")
268
+ else
269
+ render_old_checkout
270
+ end
271
+ ```
272
+
273
+ ### A/B Testing
274
+
275
+ ```ruby
276
+ # Get variant for experiment
277
+ variant = client.variant("homepage-hero", user_id: current_user.id)
278
+
279
+ case variant
280
+ when "variant_a"
281
+ # Version A: Large hero image
282
+ render_hero(size: "large", style: "image")
283
+ when "variant_b"
284
+ # Version B: Video hero
285
+ render_hero(size: "large", style: "video")
286
+ when "variant_c"
287
+ # Version C: Minimal hero
288
+ render_hero(size: "small", style: "minimal")
289
+ else
290
+ # Control: Original hero
291
+ render_hero(size: "medium", style: "image")
292
+ end
293
+
294
+ # Track conversion for this experiment (pass variant for proper attribution)
295
+ client.track("signup", user_id: current_user.id, flag_name: "homepage-hero", variant: variant)
296
+ ```
297
+
298
+ ### Conversion Tracking
299
+
300
+ ```ruby
301
+ # Track page view
302
+ client.track("page_view", metadata: { page: "/pricing" })
303
+
304
+ # Track user signup
305
+ client.track("signup", metadata: {
306
+ plan: "pro",
307
+ source: "landing_page"
308
+ })
309
+
310
+ # Track purchase with detailed metadata
311
+ client.track(
312
+ "purchase",
313
+ flag_name: "checkout-experiment",
314
+ metadata: {
315
+ amount: 149.99,
316
+ currency: "USD",
317
+ items: 3,
318
+ payment_method: "credit_card"
319
+ }
320
+ )
321
+ ```
322
+
323
+ ### Error Handling
324
+
325
+ ```ruby
326
+ require 'setbit'
327
+
328
+ begin
329
+ client = SetBit::Client.new(api_key: "invalid_key")
330
+ rescue SetBit::AuthError => e
331
+ puts "Invalid API key: #{e.message}"
332
+ # Fall back to default behavior
333
+ client = nil
334
+ end
335
+
336
+ # Client returns safe defaults if initialization failed
337
+ if client&.enabled?("new-feature")
338
+ show_new_feature
339
+ else
340
+ show_old_feature
341
+ end
342
+ ```
343
+
344
+ ### Rails Integration
345
+
346
+ ```ruby
347
+ # config/initializers/setbit.rb
348
+ Rails.application.config.setbit = SetBit::Client.new(
349
+ api_key: ENV['SETBIT_API_KEY'],
350
+ tags: {
351
+ env: Rails.env,
352
+ app: "rails-app"
353
+ },
354
+ logger: Rails.logger
355
+ )
356
+
357
+ # app/controllers/application_controller.rb
358
+ class ApplicationController < ActionController::Base
359
+ def setbit
360
+ Rails.application.config.setbit
361
+ end
362
+ helper_method :setbit
363
+ end
364
+
365
+ # app/controllers/checkout_controller.rb
366
+ class CheckoutController < ApplicationController
367
+ def new
368
+ variant = setbit.variant("checkout-flow")
369
+
370
+ if variant == "one_page"
371
+ render :one_page_checkout
372
+ else
373
+ render :multi_step_checkout
374
+ end
375
+ end
376
+
377
+ def complete
378
+ setbit.track(
379
+ "purchase",
380
+ flag_name: "checkout-flow",
381
+ metadata: { amount: params[:amount] }
382
+ )
383
+ redirect_to thank_you_path
384
+ end
385
+ end
386
+
387
+ # app/views/layouts/application.html.erb
388
+ <% if setbit.enabled?("new-header") %>
389
+ <%= render "shared/new_header" %>
390
+ <% else %>
391
+ <%= render "shared/old_header" %>
392
+ <% end %>
393
+ ```
394
+
395
+ ### Sinatra Integration
396
+
397
+ ```ruby
398
+ require 'sinatra'
399
+ require 'setbit'
400
+
401
+ configure do
402
+ set :setbit, SetBit::Client.new(
403
+ api_key: ENV['SETBIT_API_KEY'],
404
+ tags: { env: ENV['RACK_ENV'], app: "sinatra-app" }
405
+ )
406
+ end
407
+
408
+ get '/' do
409
+ if settings.setbit.enabled?("new-homepage")
410
+ erb :homepage_v2
411
+ else
412
+ erb :homepage
413
+ end
414
+ end
415
+
416
+ post '/purchase' do
417
+ # Process purchase...
418
+
419
+ settings.setbit.track(
420
+ "purchase",
421
+ metadata: { amount: params[:amount] }
422
+ )
423
+
424
+ redirect '/thank-you'
425
+ end
426
+ ```
427
+
428
+ ### Background Jobs (Sidekiq)
429
+
430
+ ```ruby
431
+ class ProcessOrderJob
432
+ include Sidekiq::Worker
433
+
434
+ def perform(order_id)
435
+ order = Order.find(order_id)
436
+
437
+ # Use SetBit in background job
438
+ client = SetBit::Client.new(
439
+ api_key: ENV['SETBIT_API_KEY'],
440
+ tags: { env: Rails.env, app: "worker" }
441
+ )
442
+
443
+ if client.enabled?("advanced-fraud-detection")
444
+ run_advanced_fraud_check(order)
445
+ else
446
+ run_basic_fraud_check(order)
447
+ end
448
+
449
+ client.track("order_processed", metadata: { order_id: order_id })
450
+ end
451
+ end
452
+ ```
453
+
454
+ ### Multi-Tenancy
455
+
456
+ ```ruby
457
+ class ApplicationController < ActionController::Base
458
+ def setbit
459
+ @setbit ||= SetBit::Client.new(
460
+ api_key: ENV['SETBIT_API_KEY'],
461
+ tags: {
462
+ env: Rails.env,
463
+ app: "web",
464
+ tenant: current_tenant.slug
465
+ }
466
+ )
467
+ end
468
+ helper_method :setbit
469
+ end
470
+
471
+ # Now flags can be targeted per tenant
472
+ # tags: { env: "production", tenant: "acme-corp" } โ†’ Show feature
473
+ # tags: { env: "production", tenant: "other-corp" } โ†’ Hide feature
474
+ ```
475
+
476
+ ## Development
477
+
478
+ ### Setup
479
+
480
+ ```bash
481
+ git clone https://github.com/setbit/setbit-ruby.git
482
+ cd setbit-ruby
483
+ bundle install
484
+ ```
485
+
486
+ ### Running Tests
487
+
488
+ ```bash
489
+ # Run all tests
490
+ bundle exec rspec
491
+
492
+ # Run specific test file
493
+ bundle exec rspec spec/client_spec.rb
494
+
495
+ # Run with coverage
496
+ bundle exec rake coverage
497
+ ```
498
+
499
+ ### Linting
500
+
501
+ ```bash
502
+ # Run RuboCop
503
+ bundle exec rubocop
504
+
505
+ # Auto-fix issues
506
+ bundle exec rubocop -a
507
+ ```
508
+
509
+ ### Generate Documentation
510
+
511
+ ```bash
512
+ bundle exec yard doc
513
+ ```
514
+
515
+ ## Error Handling
516
+
517
+ The SDK uses a **fail-open** philosophy - if something goes wrong, it returns safe default values rather than crashing your application.
518
+
519
+ ### Exception Types
520
+
521
+ - `SetBit::Error`: Base exception for all SDK errors
522
+ - `SetBit::AuthError`: Invalid API key (raised during initialization/refresh)
523
+ - `SetBit::APIError`: API request failed (raised during initialization/refresh)
524
+
525
+ ### Behavior
526
+
527
+ | Method | Error Behavior |
528
+ |--------|----------------|
529
+ | `new` | Raises exception if API key invalid or flags can't be fetched |
530
+ | `enabled?` | Returns `default` value if flag not found |
531
+ | `variant` | Returns `default` variant if flag not found |
532
+ | `track` | Logs error but does not raise exception |
533
+ | `refresh` | Raises exception if refresh fails |
534
+
535
+ ## API Endpoints
536
+
537
+ The SDK communicates with these SetBit API endpoints:
538
+
539
+ - **GET** `/api/sdk/flags` - Fetch flags for given tags
540
+ - **POST** `/api/events` - Send conversion events
541
+
542
+ ## Requirements
543
+
544
+ - Ruby >= 2.6.0
545
+ - No external runtime dependencies (uses standard library only!)
546
+
547
+ ## Support
548
+
549
+ - ๐Ÿ“š [Documentation](https://docs.setbit.io)
550
+ - ๐Ÿ’ฌ [Discord Community](https://discord.gg/setbit)
551
+ - ๐Ÿ“ง Email: support@setbit.io
552
+ - ๐Ÿ› [Report Issues](https://github.com/setbit/setbit-ruby/issues)
553
+
554
+ ## License
555
+
556
+ MIT License - see [LICENSE](LICENSE) file for details.
557
+
558
+ ## Contributing
559
+
560
+ Contributions are welcome! Please feel free to submit a Pull Request.
561
+
562
+ 1. Fork the repository
563
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
564
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
565
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
566
+ 5. Open a Pull Request
567
+
568
+ ## Roadmap
569
+
570
+ - [ ] Add memoization options for variant selection
571
+ - [ ] Support for local flag evaluation
572
+ - [ ] Streaming flag updates via WebSocket
573
+ - [ ] Performance metrics and benchmarks
574
+
575
+ ---
576
+
577
+ Made with โค๏ธ by the SetBit team
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "logger"
7
+
8
+ module SetBit
9
+ # SetBit feature flag client
10
+ #
11
+ # @example
12
+ # client = SetBit::Client.new(
13
+ # api_key: "pk_abc123",
14
+ # tags: { env: "production", app: "web" }
15
+ # )
16
+ #
17
+ # if client.enabled?("new-feature", user_id: "user_123")
18
+ # show_new_feature
19
+ # end
20
+ class Client
21
+ attr_reader :api_key, :tags, :base_url
22
+ attr_accessor :logger
23
+
24
+ # Initialize SetBit client
25
+ #
26
+ # @param api_key [String] SetBit API key (required)
27
+ # @param tags [Hash] Tags for targeting (env, app, team, region, etc.)
28
+ # @param base_url [String] API endpoint base URL
29
+ # @param logger [Logger] Optional logger instance
30
+ #
31
+ # @raise [SetBit::Error] if API key is missing
32
+ def initialize(api_key:, tags: {}, base_url: "https://flags.setbit.io", logger: nil)
33
+ raise SetBit::Error, "API key is required" if api_key.nil? || api_key.empty?
34
+
35
+ @api_key = api_key
36
+ @tags = tags || {}
37
+ @base_url = base_url.chomp("/")
38
+ @logger = logger || Logger.new(nil) # Silent logger by default
39
+ end
40
+
41
+ # Check if a flag is enabled
42
+ #
43
+ # @param flag_name [String] Name of the flag to check
44
+ # @param user_id [String] User identifier (required for analytics and billing)
45
+ # @param default [Boolean] Default value if flag not found or API fails
46
+ # @return [Boolean] true if flag is enabled, false otherwise
47
+ def enabled?(flag_name, user_id:, default: false)
48
+ url = URI("#{@base_url}/v1/evaluate")
49
+
50
+ payload = {
51
+ apiKey: @api_key,
52
+ userId: user_id,
53
+ tags: @tags,
54
+ flagName: flag_name
55
+ }
56
+
57
+ http = Net::HTTP.new(url.host, url.port)
58
+ http.use_ssl = url.scheme == "https"
59
+ http.open_timeout = 5
60
+ http.read_timeout = 5
61
+
62
+ request = Net::HTTP::Post.new(url, "Content-Type" => "application/json")
63
+ request.body = payload.to_json
64
+
65
+ response = http.request(request)
66
+
67
+ # Handle authentication errors
68
+ if response.code == "401"
69
+ @logger.error("Invalid API key")
70
+ return default
71
+ end
72
+
73
+ # Handle other errors - fail open
74
+ unless response.is_a?(Net::HTTPSuccess)
75
+ @logger.error("API error #{response.code}, returning default: #{default}")
76
+ return default
77
+ end
78
+
79
+ result = JSON.parse(response.body)
80
+ result["enabled"] || default
81
+
82
+ rescue JSON::ParserError => e
83
+ @logger.error("Failed to parse API response: #{e.message}, returning default: #{default}")
84
+ default
85
+ rescue StandardError => e
86
+ @logger.error("Failed to evaluate flag '#{flag_name}': #{e.message}, returning default: #{default}")
87
+ default
88
+ end
89
+
90
+ # Get the variant for an A/B test experiment
91
+ #
92
+ # @param flag_name [String] Name of the experiment flag
93
+ # @param user_id [String] User identifier (required)
94
+ # @param default [String] Default variant if flag not found or API fails
95
+ # @return [String] Variant name (e.g., "control", "variant_a", "variant_b")
96
+ def variant(flag_name, user_id:, default: "control")
97
+ url = URI("#{@base_url}/v1/evaluate")
98
+
99
+ payload = {
100
+ apiKey: @api_key,
101
+ userId: user_id,
102
+ tags: @tags,
103
+ flagName: flag_name
104
+ }
105
+
106
+ http = Net::HTTP.new(url.host, url.port)
107
+ http.use_ssl = url.scheme == "https"
108
+ http.open_timeout = 5
109
+ http.read_timeout = 5
110
+
111
+ request = Net::HTTP::Post.new(url, "Content-Type" => "application/json")
112
+ request.body = payload.to_json
113
+
114
+ response = http.request(request)
115
+
116
+ # Handle authentication errors
117
+ if response.code == "401"
118
+ @logger.error("Invalid API key")
119
+ return default
120
+ end
121
+
122
+ # Handle other errors - fail open
123
+ unless response.is_a?(Net::HTTPSuccess)
124
+ @logger.error("API error #{response.code}, returning default: #{default}")
125
+ return default
126
+ end
127
+
128
+ result = JSON.parse(response.body)
129
+
130
+ # If flag is disabled, return default
131
+ return default unless result["enabled"]
132
+
133
+ result["variant"] || default
134
+
135
+ rescue JSON::ParserError => e
136
+ @logger.error("Failed to parse API response: #{e.message}, returning default: #{default}")
137
+ default
138
+ rescue StandardError => e
139
+ @logger.error("Failed to get variant for '#{flag_name}': #{e.message}, returning default: #{default}")
140
+ default
141
+ end
142
+
143
+ # Track a conversion event
144
+ #
145
+ # @param event_name [String] Name of the event (e.g., "purchase", "signup")
146
+ # @param user_id [String] User identifier (required)
147
+ # @param flag_name [String, nil] Optional flag name to associate with event
148
+ # @param variant [String, nil] Optional variant the user was assigned to (for A/B test attribution)
149
+ # @param metadata [Hash] Optional metadata hash
150
+ # @return [void]
151
+ #
152
+ # @note Fails silently if tracking request fails (logs error but doesn't raise)
153
+ #
154
+ # @example Track conversion with variant attribution
155
+ # variant = client.variant("pricing-test", user_id: user_id)
156
+ # # ... later when user converts ...
157
+ # client.track("purchase", user_id: user_id, flag_name: "pricing-test", variant: variant)
158
+ def track(event_name, user_id:, flag_name: nil, variant: nil, metadata: {})
159
+ url = URI("#{@base_url}/v1/track")
160
+
161
+ payload = {
162
+ apiKey: @api_key,
163
+ userId: user_id,
164
+ eventName: event_name
165
+ }
166
+
167
+ payload[:flagName] = flag_name if flag_name
168
+ payload[:variant] = variant if variant
169
+ payload[:metadata] = metadata if metadata && !metadata.empty?
170
+
171
+ http = Net::HTTP.new(url.host, url.port)
172
+ http.use_ssl = url.scheme == "https"
173
+ http.open_timeout = 5
174
+ http.read_timeout = 5
175
+
176
+ request = Net::HTTP::Post.new(url, "Content-Type" => "application/json")
177
+ request.body = payload.to_json
178
+
179
+ response = http.request(request)
180
+
181
+ if response.is_a?(Net::HTTPSuccess)
182
+ @logger.debug("Tracked event '#{event_name}' for user '#{user_id}'")
183
+ else
184
+ @logger.error("Failed to track event '#{event_name}': HTTP #{response.code}")
185
+ end
186
+ rescue StandardError => e
187
+ @logger.error("Failed to track event '#{event_name}': #{e.message}")
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SetBit
4
+ # Base error class for SetBit SDK
5
+ class Error < StandardError; end
6
+
7
+ # Raised when API key is invalid
8
+ class AuthError < Error; end
9
+
10
+ # Raised when API request fails
11
+ class APIError < Error; end
12
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SetBit
6
+ module Utils
7
+ # Compute a consistent percentage (0-99) for a given identifier
8
+ # Uses SHA-256 hash to ensure consistent assignment
9
+ #
10
+ # @param identifier [String] User ID or other unique identifier
11
+ # @return [Integer] Integer between 0 and 99 (inclusive)
12
+ def self.compute_rollout_percentage(identifier)
13
+ hash_bytes = Digest::SHA256.digest(identifier)
14
+ # Use first 4 bytes to get a number
15
+ hash_int = hash_bytes[0, 4].unpack1("N")
16
+ hash_int % 100
17
+ end
18
+
19
+ # Select a variant using weighted random distribution
20
+ #
21
+ # @param variants [Hash] Hash mapping variant names to config with 'weight' key
22
+ # @return [String] Selected variant name
23
+ def self.select_variant(variants)
24
+ return "control" if variants.nil? || variants.empty?
25
+
26
+ total_weight = variants.values.sum { |v| v["weight"] || 0 }
27
+
28
+ return variants.keys.first || "control" if total_weight.zero?
29
+
30
+ rand_value = rand(total_weight)
31
+
32
+ cumulative = 0
33
+ variants.each do |name, config|
34
+ cumulative += config["weight"] || 0
35
+ return name if rand_value < cumulative
36
+ end
37
+
38
+ "control" # fallback
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SetBit
4
+ VERSION = "0.1.1"
5
+ end
data/lib/setbit.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "setbit/version"
4
+ require_relative "setbit/errors"
5
+ require_relative "setbit/utils"
6
+ require_relative "setbit/client"
7
+
8
+ # SetBit Ruby SDK
9
+ #
10
+ # Simple feature flag and A/B testing client for SetBit.
11
+ #
12
+ # @example
13
+ # client = SetBit::Client.new(
14
+ # api_key: "pk_abc123",
15
+ # tags: { env: "production", app: "web" }
16
+ # )
17
+ #
18
+ # if client.enabled?("new-feature")
19
+ # show_new_feature
20
+ # end
21
+ module SetBit
22
+ end
data/setbit.gemspec ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/setbit/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "setbit"
7
+ spec.version = SetBit::VERSION
8
+ spec.authors = ["SetBit"]
9
+ spec.email = ["support@setbit.io"]
10
+
11
+ spec.summary = "Ruby SDK for SetBit feature flags and A/B testing"
12
+ spec.description = "Simple and powerful feature flag and A/B testing client for SetBit. " \
13
+ "Provides boolean flags, weighted variant distribution, and conversion tracking."
14
+ spec.homepage = "https://setbit.io"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 2.6.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/setbit/setbit-ruby"
20
+ spec.metadata["changelog_uri"] = "https://github.com/setbit/setbit-ruby/blob/main/CHANGELOG.md"
21
+ spec.metadata["bug_tracker_uri"] = "https://github.com/setbit/setbit-ruby/issues"
22
+ spec.metadata["documentation_uri"] = "https://docs.setbit.io"
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ spec.files = Dir[
26
+ "lib/**/*.rb",
27
+ "README.md",
28
+ "CHANGELOG.md",
29
+ "LICENSE",
30
+ "setbit.gemspec"
31
+ ]
32
+
33
+ spec.require_paths = ["lib"]
34
+
35
+ # Runtime dependencies
36
+ # Using only standard library for HTTP - no external dependencies!
37
+
38
+ # Development dependencies
39
+ spec.add_development_dependency "rspec", "~> 3.12"
40
+ spec.add_development_dependency "webmock", "~> 3.18"
41
+ spec.add_development_dependency "rake", "~> 13.0"
42
+ spec.add_development_dependency "rubocop", "~> 1.50"
43
+ spec.add_development_dependency "yard", "~> 0.9"
44
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: setbit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - SetBit
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-11-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webmock
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.18'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.18'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.50'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.50'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ description: Simple and powerful feature flag and A/B testing client for SetBit. Provides
84
+ boolean flags, weighted variant distribution, and conversion tracking.
85
+ email:
86
+ - support@setbit.io
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE
93
+ - README.md
94
+ - lib/setbit.rb
95
+ - lib/setbit/client.rb
96
+ - lib/setbit/errors.rb
97
+ - lib/setbit/utils.rb
98
+ - lib/setbit/version.rb
99
+ - setbit.gemspec
100
+ homepage: https://setbit.io
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ homepage_uri: https://setbit.io
105
+ source_code_uri: https://github.com/setbit/setbit-ruby
106
+ changelog_uri: https://github.com/setbit/setbit-ruby/blob/main/CHANGELOG.md
107
+ bug_tracker_uri: https://github.com/setbit/setbit-ruby/issues
108
+ documentation_uri: https://docs.setbit.io
109
+ post_install_message:
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 2.6.0
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.4.10
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: Ruby SDK for SetBit feature flags and A/B testing
128
+ test_files: []