class Airship
Constants
- CONTROL_TYPE_BOOLEAN
- CONTROL_TYPE_MULTIVARIATE
- DISTRIBUTION_TYPE_PERCENTAGE_BASED
- DISTRIBUTION_TYPE_RULE_BASED
- GATING_INFO_ENDPOINT
- IDENTIFY_ENDPOINT
- OBJECT_ATTRIBUTE_TYPE_BOOLEAN
- OBJECT_ATTRIBUTE_TYPE_DATE
- OBJECT_ATTRIBUTE_TYPE_DATETIME
- OBJECT_ATTRIBUTE_TYPE_FLOAT
- OBJECT_ATTRIBUTE_TYPE_INT
- OBJECT_ATTRIBUTE_TYPE_STRING
- PLATFORM
- RULE_OPERATOR_TYPE_AFTER
- RULE_OPERATOR_TYPE_BEFORE
- RULE_OPERATOR_TYPE_FROM
- RULE_OPERATOR_TYPE_GT
- RULE_OPERATOR_TYPE_GTE
- RULE_OPERATOR_TYPE_IN
- RULE_OPERATOR_TYPE_IS
- RULE_OPERATOR_TYPE_IS_NOT
- RULE_OPERATOR_TYPE_LT
- RULE_OPERATOR_TYPE_LTE
- RULE_OPERATOR_TYPE_NOT_IN
- RULE_OPERATOR_TYPE_UNTIL
- SCHEMA
- SDK_VERSION
- SERVER_URL
- VERSION
Public Class Methods
get_hashed_value(s)
click to toggle source
# File lib/airship-ruby.rb, line 157 def get_hashed_value(s) Digest::MD5.hexdigest(s).to_i(base=16).fdiv(340282366920938463463374607431768211455) end
new(options)
click to toggle source
# File lib/airship-ruby.rb, line 162 def initialize(options) @api_key = options[:api_key] @env_key = options[:env_key] @timeout = 10 @polling_interval = 60 @ingestion_interval = 60 @polling_thread = nil @ingestion_thread = nil @gating_info = nil @gating_info_map = nil @last_ingestion_timestamp = 0 @ingestion_tasks = [] @ingestion_batch = [] @ingestion_max_batch_size = 500 @init_lock = Concurrent::Semaphore.new(1) @ingestion_batch_lock = Concurrent::Semaphore.new(1) @first_ingestion = true end
Public Instance Methods
eligible?(control_short_name, object, default_value=false)
click to toggle source
# File lib/airship-ruby.rb, line 332 def eligible?(control_short_name, object, default_value=false) object = self._shallow_copy(object) validation_errors = JSON::Validator.fully_validate(SCHEMA, object) if validation_errors.size > 0 puts validation_errors[0] return default_value end object = self._clone_object(object) self._maybe_add_fields(object) error = self._validate_nesting(object) || self._validate_anonymous(object) || self._maybe_transform_id(object) if !error.nil? puts error return default_value end if @gating_info_map.nil? return default_value end gate_timestamp = Time.now.utc.iso8601 start = Time.now gate_values = self._get_gate_values(control_short_name, object) is_enabled = gate_values['is_enabled'] variation = gate_values['variation'] is_eligible = gate_values['is_eligible'] _should_send_stats = gate_values['_should_send_stats'] finish = Time.now if _should_send_stats sdk_gate_timestamp = gate_timestamp sdk_gate_latency = "#{(finish - start) * 1000 * 1000}us" sdk_version = SDK_VERSION stats = {} stats['sdk_gate_control_short_name'] = control_short_name stats['sdk_gate_timestamp'] = sdk_gate_timestamp stats['sdk_gate_latency'] = sdk_gate_latency stats['sdk_gate_value'] = is_enabled stats['sdk_gate_variation'] = variation stats['sdk_gate_eligibility'] = is_eligible stats['sdk_gate_type'] = 'eligibility' self._enrich_with_metadata(control_short_name, stats) stats['sdk_version'] = sdk_version stats['sdk_id'] = @@sdk_id object['stats'] = stats self._ingest_async(object) end return is_eligible end
enabled?(control_short_name, object, default_value=false)
click to toggle source
# File lib/airship-ruby.rb, line 204 def enabled?(control_short_name, object, default_value=false) object = self._shallow_copy(object) validation_errors = JSON::Validator.fully_validate(SCHEMA, object) if validation_errors.size > 0 puts validation_errors[0] return default_value end object = self._clone_object(object) self._maybe_add_fields(object) error = self._validate_nesting(object) || self._validate_anonymous(object) || self._maybe_transform_id(object) if !error.nil? puts error return default_value end if @gating_info_map.nil? return default_value end gate_timestamp = Time.now.utc.iso8601 start = Time.now gate_values = self._get_gate_values(control_short_name, object) is_enabled = gate_values['is_enabled'] variation = gate_values['variation'] is_eligible = gate_values['is_eligible'] _should_send_stats = gate_values['_should_send_stats'] finish = Time.now if _should_send_stats sdk_gate_timestamp = gate_timestamp sdk_gate_latency = "#{(finish - start) * 1000 * 1000}us" sdk_version = SDK_VERSION stats = {} stats['sdk_gate_control_short_name'] = control_short_name stats['sdk_gate_timestamp'] = sdk_gate_timestamp stats['sdk_gate_latency'] = sdk_gate_latency stats['sdk_gate_value'] = is_enabled stats['sdk_gate_variation'] = variation stats['sdk_gate_eligibility'] = is_eligible stats['sdk_gate_type'] = 'value' self._enrich_with_metadata(control_short_name, stats) stats['sdk_version'] = sdk_version stats['sdk_id'] = @@sdk_id object['stats'] = stats self._ingest_async(object) end return is_enabled end
init()
click to toggle source
# File lib/airship-ruby.rb, line 188 def init @init_lock.acquire if @polling_thread.nil? self._poll @polling_thread = self._create_poller @polling_thread.execute end if @ingestion_thread.nil? @ingestion_thread = self._create_ingestor @ingestion_thread.execute end @init_lock.release self end
variation(control_short_name, object, default_value=nil)
click to toggle source
# File lib/airship-ruby.rb, line 268 def variation(control_short_name, object, default_value=nil) object = self._shallow_copy(object) validation_errors = JSON::Validator.fully_validate(SCHEMA, object) if validation_errors.size > 0 puts validation_errors[0] return default_value end object = self._clone_object(object) self._maybe_add_fields(object) error = self._validate_nesting(object) || self._validate_anonymous(object) || self._maybe_transform_id(object) if !error.nil? puts error return default_value end if @gating_info_map.nil? return default_value end gate_timestamp = Time.now.utc.iso8601 start = Time.now gate_values = self._get_gate_values(control_short_name, object) is_enabled = gate_values['is_enabled'] variation = gate_values['variation'] is_eligible = gate_values['is_eligible'] _should_send_stats = gate_values['_should_send_stats'] finish = Time.now if _should_send_stats sdk_gate_timestamp = gate_timestamp sdk_gate_latency = "#{(finish - start) * 1000 * 1000}us" sdk_version = SDK_VERSION stats = {} stats['sdk_gate_control_short_name'] = control_short_name stats['sdk_gate_timestamp'] = sdk_gate_timestamp stats['sdk_gate_latency'] = sdk_gate_latency stats['sdk_gate_value'] = is_enabled stats['sdk_gate_variation'] = variation stats['sdk_gate_eligibility'] = is_eligible stats['sdk_gate_type'] = 'variation' self._enrich_with_metadata(control_short_name, stats) stats['sdk_version'] = sdk_version stats['sdk_id'] = @@sdk_id object['stats'] = stats self._ingest_async(object) end return variation end
Protected Instance Methods
_clone_object(object)
click to toggle source
# File lib/airship-ruby.rb, line 932 def _clone_object(object) copy = object.clone if !object['attributes'].nil? copy['attributes'] = object['attributes'].clone end if !object['group'].nil? copy['group'] = object['group'].clone if !object['group']['attributes'].nil? copy['group']['attributes'] = object['group']['attributes'].clone end end copy end
_create_ingestor()
click to toggle source
# File lib/airship-ruby.rb, line 488 def _create_ingestor Concurrent::TimerTask.new(execution_interval: @ingestion_interval, timeout_interval: @timeout) do |task| now = Time.now.utc.to_i if now - @last_ingestion_timestamp >= @ingestion_interval processed = self._process_batch(0) if processed @last_ingestion_timestamp = now end end task.execution_interval = @ingestion_interval end end
_create_poller()
click to toggle source
# File lib/airship-ruby.rb, line 481 def _create_poller Concurrent::TimerTask.new(execution_interval: @polling_interval, timeout_interval: @timeout) do |task| self._poll task.execution_interval = @polling_interval end end
_create_processor(payload)
click to toggle source
# File lib/airship-ruby.rb, line 501 def _create_processor(payload) return Concurrent::ScheduledTask.execute(0) do |task| conn = Faraday.new(url: IDENTIFY_ENDPOINT) response = conn.post do |req| req.options.timeout = @timeout req.headers['Content-Type'] = 'application/json' req.headers['api-key'] = @api_key req.body = JSON.generate({ 'env_key' => @env_key, 'objects' => payload, }) end end end
_enrich_with_metadata(control_short_name, stats)
click to toggle source
# File lib/airship-ruby.rb, line 993 def _enrich_with_metadata(control_short_name, stats) control_info = @gating_info_map[control_short_name] if !control_info.nil? stats['sdk_gate_control_id'] = control_info['id'] end stats['sdk_env_id'] = @gating_info['env']['id'] end
_get_gate_values(control_short_name, object)
click to toggle source
# File lib/airship-ruby.rb, line 866 def _get_gate_values(control_short_name, object) if @gating_info_map[control_short_name].nil? return { 'is_enabled' => false, 'variation' => nil, 'is_eligible' => false, '_should_send_stats' => false, } end control_info = @gating_info_map[control_short_name] if !control_info['is_on'] return { 'is_enabled' => false, 'variation' => nil, 'is_eligible' => false, '_should_send_stats' => true, } end group = nil if !object['group'].nil? group = object['group'] end result = self._get_gate_values_for_object(control_info, object) if !group.nil? if group['type'].nil? group['type'] = "#{object['type']}Group" group['is_group'] = true end group_result = self._get_gate_values_for_object(control_info, group) if result['_from_enablement'] == true && !result['is_enabled'] # Do nothing elsif result['_from_enablement'] != true && group_result['_from_enablement'] == true && !group_result['is_enabled'] result['is_enabled'] = group_result['is_enabled'] result['variation'] = group_result['variation'] result['is_eligible'] = group_result['is_eligible'] elsif result['is_enabled'] if result['_rule_based_default_variation'] == true if group_result['is_enabled'] result['is_enabled'] = group_result['is_enabled'] result['variation'] = group_result['variation'] result['is_eligible'] = group_result['is_eligible'] else # Do nothing end else # Do nothing end elsif group_result['is_enabled'] result['is_enabled'] = group_result['is_enabled'] result['variation'] = group_result['variation'] result['is_eligible'] = group_result['is_eligible'] else # Do nothing end end result['_should_send_stats'] = true result end
_get_gate_values_for_object(control_info, object)
click to toggle source
# File lib/airship-ruby.rb, line 711 def _get_gate_values_for_object(control_info, object) if !control_info['enablements_info'][object['type']].nil? if !control_info['enablements_info'][object['type']][object['id']].nil? is_enabled, variation = control_info['enablements_info'][object['type']][object['id']] return { 'is_enabled' => is_enabled, 'variation' => variation, 'is_eligible' => is_enabled, '_from_enablement' => true, } end end sampled_inside_base_population = false is_eligible = false control_info['rule_sets'].each do |rule_set| if sampled_inside_base_population break end rules = rule_set['rules'] if rule_set['client_object_type_name'] != object['type'] next end satisfies_all_rules = true rules.each do |rule| satisfies_all_rules = satisfies_all_rules && self._satisfies_rule(rule, object) end if satisfies_all_rules is_eligible = true hash_key = "SAMPLING:control_#{control_info['id']}:env_#{@gating_info['env']['id']}:rule_set_#{rule_set['id']}:client_object_#{object['type']}_#{object['id']}" if Airship.get_hashed_value(hash_key) <= rule_set['sampling_percentage'] sampled_inside_base_population = true end end end if !sampled_inside_base_population return { 'is_enabled' => false, 'variation' => nil, 'is_eligible' => is_eligible, } end if control_info['type'] == CONTROL_TYPE_BOOLEAN return { 'is_enabled' => true, 'variation' => nil, 'is_eligible' => true, } elsif control_info['type'] == CONTROL_TYPE_MULTIVARIATE if control_info['distributions'].size == 0 return { 'is_enabled' => true, 'variation' => control_info['default_variation'], 'is_eligible' => true, } end percentage_based_distributions = control_info['distributions'].select { |d| d['type'] == DISTRIBUTION_TYPE_PERCENTAGE_BASED } rule_based_distributions = control_info['distributions'].select { |d| d['type'] == DISTRIBUTION_TYPE_RULE_BASED } if percentage_based_distributions.size != 0 && rule_based_distributions.size != 0 puts 'Rule integrity error: please contact support@airshiphq.com' return { 'is_enabled' => false, 'variation' => nil, 'is_eligible' => false, } end if percentage_based_distributions.size != 0 delta = 0.0001 sum_percentages = 0.0 running_percentages = [] percentage_based_distributions.each do |distribution| sum_percentages += distribution['percentage'] if running_percentages.size == 0 running_percentages.push(distribution['percentage']) else running_percentages.push(running_percentages[running_percentages.size - 1] + distribution['percentage']) end end if (1.0 - sum_percentages).abs > delta puts 'Rule integrity error: please contact support@airshiphq.com' return { 'is_enabled' => false, 'variation' => nil, 'is_eligible' => false, } end hash_key = "DISTRIBUTION:control_#{control_info['id']}:env_#{@gating_info['env']['id']}:client_object_#{object['type']}_#{object['id']}" hashed_percentage = Airship.get_hashed_value(hash_key) running_percentages.each_with_index do |percentage, i| if hashed_percentage <= percentage return { 'is_enabled' => true, 'variation' => percentage_based_distributions[i]['variation'], 'is_eligible' => true, } end end return { 'is_enabled' => true, 'variation' => percentage_based_distributions[percentage_based_distributions.size - 1]['variation'], 'is_eligible' => true, } else rule_based_distributions.each do |distribution| rule_set = distribution['rule_set'] rules = rule_set['rules'] if rule_set['client_object_type_name'] != object['type'] next end satisfies_all_rules = true rules.each do |rule| satisfies_all_rules = satisfies_all_rules && self._satisfies_rule(rule, object) end if satisfies_all_rules return { 'is_enabled' => true, 'variation' => distribution['variation'], 'is_eligible' => true, } end end return { 'is_enabled' => true, 'variation' => control_info['rule_based_distribution_default_variation'] || control_info['default_variation'], 'is_eligible' => true, '_rule_based_default_variation' => true, } end else return { 'is_enabled' => false, 'variation' => nil, 'is_eligible' => false, } end end
_get_gating_info_map(gating_info)
click to toggle source
# File lib/airship-ruby.rb, line 410 def _get_gating_info_map(gating_info) map = {} controls = gating_info['controls'] controls.each do |control| control_info = {} control_info['id'] = control['id'] control_info['is_on'] = control['is_on'] control_info['rule_based_distribution_default_variation'] = control['rule_based_distribution_default_variation'] control_info['rule_sets'] = control['rule_sets'] control_info['distributions'] = control['distributions'] control_info['type'] = control['type'] control_info['default_variation'] = control['default_variation'] enablements = control['enablements'] enablements_info = {} enablements.each do |enablement| client_identities_map = enablements_info[enablement['client_object_type_name']] if client_identities_map.nil? enablements_info[enablement['client_object_type_name']] = {} end enablements_info[enablement['client_object_type_name']][enablement['client_object_identity']] = [enablement['is_enabled'], enablement['variation']] end control_info['enablements_info'] = enablements_info map[control['short_name']] = control_info end map end
_ingest_async(gate_stats)
click to toggle source
# File lib/airship-ruby.rb, line 547 def _ingest_async(gate_stats) processed = self._process_batch(@ingestion_max_batch_size - 1, gate_stats) if processed now = Time.now.utc.to_i @last_ingestion_timestamp = now end end
_maybe_add_fields(object)
click to toggle source
# File lib/airship-ruby.rb, line 1003 def _maybe_add_fields(object) if object['type'].nil? object['type'] = 'User' end if object['display_name'].nil? object['display_name'] = object['id'].to_s end if !object['group'].nil? && object['group']['display_name'].nil? object['group']['display_name'] = object['group']['id'].to_s end end
_maybe_transform_id(object)
click to toggle source
# File lib/airship-ruby.rb, line 966 def _maybe_transform_id(object) if object['id'].is_a?(Integer) id_str = object['id'].to_s if id_str.length > 250 return 'Integer id must have 250 digits or less' end object['id'] = id_str end group = nil if !object['group'].nil? group = object['group'] end if !group.nil? if group['id'].is_a?(Integer) id_str = group['id'].to_s if id_str.length > 250 return 'Integer id must have 250 digits or less' end group['id'] = id_str end end nil end
_poll()
click to toggle source
# File lib/airship-ruby.rb, line 447 def _poll begin conn = Faraday.new(url: "#{GATING_INFO_ENDPOINT}/#{@env_key}") response = conn.get do |req| req.options.timeout = @timeout req.headers['api-key'] = @api_key end if response.status == 200 gating_info = JSON.parse(response.body) if gating_info['server_info'] == 'maintenance' puts 'Airship is currently going through maintenance' return end gating_info_map = self._get_gating_info_map(gating_info) @gating_info = gating_info @gating_info_map = gating_info_map if !gating_info['polling_interval'].nil? @polling_interval = gating_info['polling_interval'] end if !gating_info['ingestion_interval'].nil? @ingestion_interval = gating_info['ingestion_interval'] end else puts 'Failed to connect to Airship server' end rescue Exception => e puts 'Failed to connect to Airship server' end end
_process_batch(limit, gate_stats=nil)
click to toggle source
# File lib/airship-ruby.rb, line 516 def _process_batch(limit, gate_stats=nil) # This is sort of a weird function. # We process the batch if the batch size # is more than limit. The second param # allows for an additional gate_states to # be inserted before the processing check # is performed. processed = false @ingestion_batch_lock.acquire if !gate_stats.nil? @ingestion_batch.push(gate_stats) end if @ingestion_batch.size > limit || (@first_ingestion && @ingestion_batch.size > 0) @first_ingestion = false new_ingestion_tasks = [] @ingestion_tasks.each do |task| if !task.fulfilled? new_ingestion_tasks.push(task) end end payload = @ingestion_batch @ingestion_batch = [] new_ingestion_tasks.push(self._create_processor(payload)) @ingestion_tasks = new_ingestion_tasks processed = true end @ingestion_batch_lock.release processed end
_satisfies_rule(rule, object)
click to toggle source
# File lib/airship-ruby.rb, line 555 def _satisfies_rule(rule, object) attribute_type = rule['attribute_type'] operator = rule['operator'] attribute_name = rule['attribute_name'] value = rule['value'] value_list = rule['value_list'] if object['attributes'].nil? || object['attributes'][attribute_name].nil? return false end attribute_val = object['attributes'][attribute_name] if attribute_type == OBJECT_ATTRIBUTE_TYPE_STRING if operator == RULE_OPERATOR_TYPE_IS return attribute_val == value elsif operator == RULE_OPERATOR_TYPE_IS_NOT return attribute_val != value elsif operator == RULE_OPERATOR_TYPE_IN return !value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_NOT_IN return value_list.index(attribute_val).nil? else return false end elsif attribute_type == OBJECT_ATTRIBUTE_TYPE_INT value = value && value.to_i value_list = value_list && value_list.map { |v| v.to_i } if operator == RULE_OPERATOR_TYPE_IS return attribute_val == value elsif operator == RULE_OPERATOR_TYPE_IS_NOT return attribute_val != value elsif operator == RULE_OPERATOR_TYPE_IN return !value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_NOT_IN return value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_LT return attribute_val < value elsif operator == RULE_OPERATOR_TYPE_LTE return attribute_val <= value elsif operator == RULE_OPERATOR_TYPE_GT return attribute_val > value elsif operator == RULE_OPERATOR_TYPE_GTE return attribute_val >= value else return false end elsif attribute_type == OBJECT_ATTRIBUTE_TYPE_FLOAT value = value && value.to_f value_list = value_list && value_list.map { |v| v.to_f } if operator == RULE_OPERATOR_TYPE_IS return attribute_val == value elsif operator == RULE_OPERATOR_TYPE_IS_NOT return attribute_val != value elsif operator == RULE_OPERATOR_TYPE_IN return !value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_NOT_IN return value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_LT return attribute_val < value elsif operator == RULE_OPERATOR_TYPE_LTE return attribute_val <= value elsif operator == RULE_OPERATOR_TYPE_GT return attribute_val > value elsif operator == RULE_OPERATOR_TYPE_GTE return attribute_val >= value else return false end elsif attribute_type == OBJECT_ATTRIBUTE_TYPE_BOOLEAN value = (value == 'true') ? true : false if operator == RULE_OPERATOR_TYPE_IS return attribute_val == value elsif operator == RULE_OPERATOR_TYPE_IS_NOT return attribute_val != value else return false end elsif attribute_type == OBJECT_ATTRIBUTE_TYPE_DATE unix_timestamp = nil begin unix_timestamp = DateTime.parse(attribute_val).to_time.to_i rescue Exception => e return false end iso_format = DateTime.parse(attribute_val).iso8601 if !iso_format.end_with?('T00:00:00+00:00') return false end value = value && DateTime.parse(value).to_time.to_i value_list = value_list && value_list.map { |v| DateTime.parse(v).to_time.to_i } attribute_val = unix_timestamp if operator == RULE_OPERATOR_TYPE_IS return attribute_val == value elsif operator == RULE_OPERATOR_TYPE_IS_NOT return attribute_val != value elsif operator == RULE_OPERATOR_TYPE_IN return !value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_NOT_IN return value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_FROM return attribute_val >= value elsif operator == RULE_OPERATOR_TYPE_UNTIL return attribute_val <= value elsif operator == RULE_OPERATOR_TYPE_AFTER return attribute_val > value elsif operator == RULE_OPERATOR_TYPE_BEFORE return attribute_val < value else return false end elsif attribute_type == OBJECT_ATTRIBUTE_TYPE_DATETIME # to_time.to_i respects timezones unix_timestamp = nil begin unix_timestamp = DateTime.parse(attribute_val).to_time.to_i rescue Exception => e return false end value = value && DateTime.parse(value).to_time.to_i value_list = value_list && value_list.map { |v| DateTime.parse(v).to_time.to_i } attribute_val = unix_timestamp if operator == RULE_OPERATOR_TYPE_IS return attribute_val == value elsif operator == RULE_OPERATOR_TYPE_IS_NOT return attribute_val != value elsif operator == RULE_OPERATOR_TYPE_IN return !value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_NOT_IN return value_list.index(attribute_val).nil? elsif operator == RULE_OPERATOR_TYPE_FROM return attribute_val >= value elsif operator == RULE_OPERATOR_TYPE_UNTIL return attribute_val <= value elsif operator == RULE_OPERATOR_TYPE_AFTER return attribute_val > value elsif operator == RULE_OPERATOR_TYPE_BEFORE return attribute_val < value else return false end else return false end end
_shallow_copy(object)
click to toggle source
# File lib/airship-ruby.rb, line 398 def _shallow_copy(object) copy = {} if object.is_a?(Hash) object.each do |key, value| copy[key.to_s] = self._shallow_copy(value) end else return object end copy end
_validate_anonymous(object)
click to toggle source
# File lib/airship-ruby.rb, line 956 def _validate_anonymous(object) if object['is_anonymous'] == true && !object['group'].nil? return 'An anonymous object cannot belong to a group' end if object['is_group'] === true && object['is_anonymous'] === true return 'A group cannot be anonymous' end end
_validate_nesting(object)
click to toggle source
# File lib/airship-ruby.rb, line 950 def _validate_nesting(object) if object['is_group'] == true && !object['group'].nil? return 'A group cannot be nested inside another group' end end