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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ jobs:
strategy:
matrix:
ruby-version:
- '2.3'
- '2.5'
- '2.6'
- '2.7'
- '3.0'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't add a test coverage here. i think it's better to add codecov integration here so we know it's being properly covered.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added now - Needs to go into default branch but coverage looks solid

Coverage report generated for RSpec to /Users/kwameefah/mixpanel-ruby/coverage. Line Coverage: 96.45% (544 / 564) Coverage report generated for RSpec to /Users/kwameefah/mixpanel-ruby/coverage/coverage.xml. Line Coverage: 96.45% (544 / 564)

- '3.1'
- '3.2'
Expand All @@ -34,3 +30,8 @@ jobs:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Run tests
run: bundle exec rake
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: mixpanel/mixpanel-ruby
25 changes: 25 additions & 0 deletions demo/flags/local_flags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
require 'mixpanel-ruby'

# Configuration
PROJECT_TOKEN = ""
FLAG_KEY = "sample-flag"
FLAG_FALLBACK_VARIANT = "control"
USER_CONTEXT = { "distinct_id" => "ruby-demo-user" }
API_HOST = "api.mixpanel.com"
SHOULD_POLL_CONTINUOUSLY = true
POLLING_INTERVAL_SECONDS = 15

local_config = {
api_host: API_HOST,
enable_polling: SHOULD_POLL_CONTINUOUSLY,
polling_interval_in_seconds: POLLING_INTERVAL_SECONDS
}

tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, local_flags_config: local_config)

tracker.local_flags.start_polling_for_definitions!

variant_value = tracker.local_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
puts "Variant value: #{variant_value}"

tracker.local_flags.stop_polling_for_definitions!
18 changes: 18 additions & 0 deletions demo/flags/remote_flags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
require 'mixpanel-ruby'

# Configuration
PROJECT_TOKEN = ""
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty string credentials in demo file present a security risk. Line 4 initializes PROJECT_TOKEN as an empty string. While this is a demo file, it should include a clear comment warning users not to commit actual tokens, or use environment variables instead (e.g., ENV['MIXPANEL_PROJECT_TOKEN']) to prevent accidental credential exposure.

Suggested change
PROJECT_TOKEN = ""
# NOTE: Do not hardcode or commit real project tokens in source control.
# For real projects, set MIXPANEL_PROJECT_TOKEN in your environment instead.
PROJECT_TOKEN = ENV['MIXPANEL_PROJECT_TOKEN'] || ""

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project token is not a secret

FLAG_KEY = "sample-flag"
FLAG_FALLBACK_VARIANT = "control"
USER_CONTEXT = { "distinct_id" => "ruby-demo-user" }
API_HOST = "api.mixpanel.com"

remote_config = {
api_host: API_HOST
}

tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, remote_flags_config: remote_config)

variant_value = tracker.remote_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
puts "Variant value: #{variant_value}"

5 changes: 5 additions & 0 deletions lib/mixpanel-ruby.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
require 'mixpanel-ruby/consumer.rb'
require 'mixpanel-ruby/tracker.rb'
require 'mixpanel-ruby/version.rb'
require 'mixpanel-ruby/flags/utils.rb'
require 'mixpanel-ruby/flags/types.rb'
require 'mixpanel-ruby/flags/flags_provider.rb'
require 'mixpanel-ruby/flags/local_flags_provider.rb'
require 'mixpanel-ruby/flags/remote_flags_provider.rb'
111 changes: 111 additions & 0 deletions lib/mixpanel-ruby/flags/flags_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
require 'net/https'
require 'json'
require 'uri'
require 'mixpanel-ruby/version'
require 'mixpanel-ruby/error'
require 'mixpanel-ruby/flags/utils'
require 'mixpanel-ruby/flags/types'

module Mixpanel
module Flags

# Base class for feature flags providers
# Provides common HTTP handling and exposure event tracking
class FlagsProvider
# @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds
# @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions')
# @param tracker_callback [Proc] Function used to track events (bound tracker.track method)
# @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote'
# @param error_handler [Mixpanel::ErrorHandler] Error handler instance
def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, error_handler)
@provider_config = provider_config
@endpoint = endpoint
@tracker_callback = tracker_callback
@evaluation_mode = evaluation_mode
@error_handler = error_handler
end

# Make HTTP request to flags API endpoint
# @param additional_params [Hash, nil] Additional query parameters
# @return [Hash] Parsed JSON response
# @raise [Mixpanel::ConnectionError] on network errors
# @raise [Mixpanel::ServerError] on HTTP errors
def call_flags_endpoint(additional_params = nil)
common_params = Utils.prepare_common_query_params(
@provider_config[:token],
Mixpanel::VERSION
)

params = common_params.merge(additional_params || {})
query_string = URI.encode_www_form(params)

uri = URI::HTTPS.build(
host: @provider_config[:api_host],
path: @endpoint,
query: query_string
)

http = Net::HTTP.new(uri.host, uri.port)

http.use_ssl = true
http.open_timeout = @provider_config[:request_timeout_in_seconds]
http.read_timeout = @provider_config[:request_timeout_in_seconds]

request = Net::HTTP::Get.new(uri.request_uri)

request.basic_auth(@provider_config[:token], '')

request['Content-Type'] = 'application/json'
request['traceparent'] = Utils.generate_traceparent()

begin
response = http.request(request)

unless response.code.to_i == 200
raise ServerError.new("HTTP #{response.code}: #{response.body}")
end

JSON.parse(response.body)
rescue Net::OpenTimeout, Net::ReadTimeout => e
raise ConnectionError.new("Request timeout: #{e.message}")
rescue JSON::ParserError => e
raise ServerError.new("Invalid JSON response: #{e.message}")
rescue StandardError => e
raise ConnectionError.new("Network error: #{e.message}")
end
end

# Track exposure event to Mixpanel
# @param flag_key [String] Feature flag key
# @param selected_variant [SelectedVariant] The selected variant
# @param context [Hash] User context (must include 'distinct_id')
# @param latency_ms [Integer, nil] Optional latency in milliseconds
def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil)
distinct_id = context['distinct_id'] || context[:distinct_id]

unless distinct_id
return
end

properties = {
'distinct_id' => distinct_id,
'Experiment name' => flag_key,
'Variant name' => selected_variant.variant_key,
'$experiment_type' => 'feature_flag',
'Flag evaluation mode' => @evaluation_mode
}

properties['Variant fetch latency (ms)'] = latency_ms if latency_ms
properties['$experiment_id'] = selected_variant.experiment_id if selected_variant.experiment_id
properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil?
properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil?

begin
@tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
rescue MixpanelError => e
@error_handler.handle(e)
end
end
end
end
end
Loading