From 57885d494350c5f309676dc508d40aaec9ad6724 Mon Sep 17 00:00:00 2001 From: Ved Petkar Date: Thu, 12 Dec 2019 14:49:46 -0500 Subject: [PATCH] Add class definition style --- lib/verdict.rb | 1 + lib/verdict/experiment_definition.rb | 247 +++++++++++++++++++++++++++ lib/verdict/railtie.rb | 7 - lib/verdict/version.rb | 4 +- verdict.gemspec | 2 +- 5 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 lib/verdict/experiment_definition.rb diff --git a/lib/verdict.rb b/lib/verdict.rb index a11c80b..7f74653 100644 --- a/lib/verdict.rb +++ b/lib/verdict.rb @@ -48,6 +48,7 @@ def initialize(handle) require "verdict/railtie" if defined?(Rails::Railtie) require "verdict/metadata" +require "verdict/experiment_definition" require "verdict/experiment" require "verdict/group" require "verdict/assignment" diff --git a/lib/verdict/experiment_definition.rb b/lib/verdict/experiment_definition.rb new file mode 100644 index 0000000..f8584fd --- /dev/null +++ b/lib/verdict/experiment_definition.rb @@ -0,0 +1,247 @@ +module Verdict + class ExperimentDefinition + extend Verdict::Metadata + + class << self + attr_reader :handle + + def inherited(subclass) + @handle = subclass.to_s.underscore.gsub('/', '_') + end + + def event_logger + Verdict::EventLogger.new(Verdict.default_logger) + end + + def subject_type + nil + end + + def store_unqualified? + !!@store_unqualified + end + + def manual_assignment_timestamps? + nil + end + + def group(handle) + segmenter.groups[handle.to_s] + end + + def groups(segmenter_class = Verdict::Segmenters::FixedPercentageSegmenter, &block) + return segmenter.groups unless block_given? + @segmenter ||= segmenter_class.new(self) + @segmenter.instance_eval(&block) + @segmenter.verify! + return self + end + + def rollout_percentage(percentage, rollout_group_name = :enabled) + groups(Verdict::Segmenters::RolloutSegmenter) do + group(rollout_group_name, percentage) + end + end + + def qualifiers + @qualifiers ||= [] + end + + def qualify(method_name = nil, &block) + if block_given? + qualifiers << block + elsif method_name.nil? + raise ArgumentError, "no method nor blocked passed!" + elsif respond_to?(method_name, true) + qualifiers << method(method_name).to_proc + else + raise ArgumentError, "No helper for #{method_name.inspect}" + end + end + + def storage(storage = :memory, options = {}) + @store_unqualified = options[:store_unqualified] if options.has_key?(:store_unqualified) + case storage + when :memory + Verdict::Storage::MemoryStorage.new + when :none + Verdict::Storage::MockStorage.new + when Class + storage.new + end + end + + def segmenter + raise Verdict::Error, "No groups defined for experiment #{@handle.inspect}." if @segmenter.nil? + @segmenter + end + + def started_at + @started_at ||= storage.retrieve_start_timestamp(self) + rescue Verdict::StorageError + nil + end + + def started? + !@started_at.nil? + end + + def group_handles + segmenter.groups.keys + end + + def subject_assignment(subject, group, originally_created_at = nil, temporary = false) + Verdict::Assignment.new(self, subject, group, originally_created_at, temporary) + end + + def subject_conversion(subject, goal, created_at = Time.now.utc) + Verdict::Conversion.new(self, subject, goal, created_at) + end + + def convert(subject, goal) + identifier = retrieve_subject_identifier(subject) + conversion = subject_conversion(subject, goal) + event_logger.log_conversion(conversion) + segmenter.conversion_feedback(identifier, subject, conversion) + conversion + rescue Verdict::EmptySubjectIdentifier + raise unless disqualify_empty_identifier? + end + + def assign(subject, context = nil) + previous_assignment = lookup(subject) + + subject_identifier = retrieve_subject_identifier(subject) + assignment = if previous_assignment + previous_assignment + elsif subject_qualifies?(subject, context) + group = segmenter.assign(subject_identifier, subject, context) + subject_assignment(subject, group, nil, group.nil?) + else + nil_assignment(subject) + end + + store_assignment(assignment) + rescue Verdict::StorageError + nil_assignment(subject) + rescue Verdict::EmptySubjectIdentifier + if disqualify_empty_identifier? + nil_assignment(subject) + else + raise + end + end + + def assign_manually(subject, group) + assignment = subject_assignment(subject, group) + if !assignment.qualified? && !store_unqualified? + raise Verdict::Error, "Unqualified subject assignments are not stored for this experiment, so manual disqualification is impossible. Consider setting :store_unqualified to true for this experiment." + end + + store_assignment(assignment) + assignment + end + + def disqualify_manually(subject) + assign_manually(subject, nil) + end + + def store_assignment(assignment) + storage.store_assignment(assignment) if should_store_assignment?(assignment) + event_logger.log_assignment(assignment) + assignment + end + + def cleanup(options = {}) + storage.cleanup(self, options) + end + + def remove_subject_assignment(subject) + storage.remove_assignment(self, subject) + end + + def switch(subject, context = nil) + assign(subject, context).to_sym + end + + def lookup(subject) + storage.retrieve_assignment(self, subject) + end + + def retrieve_subject_identifier(subject) + identifier = subject_identifier(subject).to_s + raise Verdict::EmptySubjectIdentifier, "Subject resolved to an empty identifier!" if identifier.empty? + identifier + end + + def has_qualifier? + @qualifiers.any? + end + + def everybody_qualifies? + !has_qualifier? + end + + def as_json(options = {}) + { + handle: handle, + has_qualifier: has_qualifier?, + groups: segmenter.groups.values.map { |group| group.as_json(options) }, + metadata: metadata, + started_at: started_at.nil? ? nil : started_at.utc.strftime('%FT%TZ') + }.tap do |data| + data[:subject_type] = subject_type.to_s unless subject_type.nil? + end + end + + def to_json(options = {}) + as_json(options).to_json + end + + def fetch_subject(subject_identifier) + raise NotImplementedError, "Fetching subjects based on identifier is not implemented for experiment #{handle.inspect}." + end + + def disqualify_empty_identifier? + @disqualify_empty_identifier + end + + def subject_qualifies?(subject, context = nil) + ensure_experiment_has_started + everybody_qualifies? || qualifiers.all? { |qualifier| qualifier.call(subject, context) } + end + + protected + + def default_options + {} + end + + def should_store_assignment?(assignment) + assignment.permanent? && !assignment.returning? && (store_unqualified? || assignment.qualified?) + end + + def subject_identifier(subject) + subject.respond_to?(:id) ? subject.id : subject.to_s + end + + def set_start_timestamp + storage.store_start_timestamp(self, started_now = Time.now.utc) + started_now + rescue NotImplementedError + nil + end + + def ensure_experiment_has_started + @started_at ||= started_at || set_start_timestamp + rescue Verdict::StorageError + @started_at ||= Time.now.utc + end + + def nil_assignment(subject) + subject_assignment(subject, nil, nil) + end + end + end +end + diff --git a/lib/verdict/railtie.rb b/lib/verdict/railtie.rb index 7ff44a1..ecd1ca9 100644 --- a/lib/verdict/railtie.rb +++ b/lib/verdict/railtie.rb @@ -1,14 +1,7 @@ class Verdict::Railtie < Rails::Railtie initializer "experiments.configure_rails_initialization" do |app| - app.config.eager_load_namespaces << Verdict - Verdict.default_logger = Rails.logger - Verdict.directory ||= Rails.root.join('app', 'experiments') - app.config.eager_load_paths -= Dir[Verdict.directory.to_s] - - # Re-freeze eager load paths to ensure they blow up if modified at runtime, as Rails does - app.config.eager_load_paths.freeze end config.to_prepare do diff --git a/lib/verdict/version.rb b/lib/verdict/version.rb index bbb1314..665777c 100644 --- a/lib/verdict/version.rb +++ b/lib/verdict/version.rb @@ -1,3 +1,5 @@ module Verdict - VERSION = "0.12.0" + class Version + VERSION = "0.13.0" + end end diff --git a/verdict.gemspec b/verdict.gemspec index 2db9f21..9ff024e 100644 --- a/verdict.gemspec +++ b/verdict.gemspec @@ -5,7 +5,7 @@ require 'verdict/version' Gem::Specification.new do |gem| gem.name = "verdict" - gem.version = Verdict::VERSION + gem.version = Verdict::Version::VERSION gem.authors = ["Shopify"] gem.email = ["kevin.mcphillips@shopify.com", "willem@shopify.com"] gem.description = %q{Shopify Experiments classes}