PageRenderTime 65ms CodeModel.GetById 36ms RepoModel.GetById 0ms app.codeStats 0ms

/episode-214/todo/vendor/plugins/abingo/lib/abingo.rb

https://github.com/ryanb/railscasts-episodes
Ruby | 225 lines | 147 code | 23 blank | 55 comment | 21 complexity | ad774725df5c5f9b4d5201879cc6630f MD5 | raw file
Possible License(s): MIT, Apache-2.0
  1. #This class is outside code's main interface into the ABingo A/B testing framework.
  2. #Unless you're fiddling with implementation details, it is the only one you need worry about.
  3. #Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
  4. class Abingo
  5. @@VERSION = "1.0.0"
  6. @@MAJOR_VERSION = "1.0"
  7. cattr_reader :VERSION
  8. cattr_reader :MAJOR_VERSION
  9. #Not strictly necessary, but eh, as long as I'm here.
  10. cattr_accessor :salt
  11. @@salt = "Not really necessary."
  12. @@options ||= {}
  13. cattr_accessor :options
  14. #Defined options:
  15. # :enable_specification => if true, allow params[test_name] to override the calculated value for a test.
  16. #ABingo stores whether a particular user has participated in a particular
  17. #experiment yet, and if so whether they converted, in the cache.
  18. #
  19. #It is STRONGLY recommended that you use a MemcacheStore for this.
  20. #If you'd like to persist this through a system restart or the like, you can
  21. #look into memcachedb, which speaks the memcached protocol. From the perspective
  22. #of Rails it is just another MemcachedStore.
  23. #
  24. #You can overwrite Abingo's cache instance, if you would like it to not share
  25. #your generic Rails cache.
  26. cattr_writer :cache
  27. def self.cache
  28. @@cache || Rails.cache
  29. end
  30. #This method gives a unique identity to a user. It can be absolutely anything
  31. #you want, as long as it is consistent.
  32. #
  33. #We use the identity to determine, deterministically, which alternative a user sees.
  34. #This means that if you use Abingo.identify_user on someone at login, they will
  35. #always see the same alternative for a particular test which is past the login
  36. #screen. For details and usage notes, see the docs.
  37. def self.identity=(new_identity)
  38. @@identity = new_identity.to_s
  39. end
  40. def self.identity
  41. @@identity ||= rand(10 ** 10).to_i.to_s
  42. end
  43. #A simple convenience method for doing an A/B test. Returns true or false.
  44. #If you pass it a block, it will bind the choice to the variable given to the block.
  45. def self.flip(test_name)
  46. if block_given?
  47. yield(self.test(test_name, [true, false]))
  48. else
  49. self.test(test_name, [true, false])
  50. end
  51. end
  52. #This is the meat of A/Bingo.
  53. #options accepts
  54. # :multiple_participation (true or false)
  55. # :conversion name of conversion to listen for (alias: conversion_name)
  56. def self.test(test_name, alternatives, options = {})
  57. short_circuit = Abingo.cache.read("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"))
  58. unless short_circuit.nil?
  59. return short_circuit #Test has been stopped, pick canonical alternative.
  60. end
  61. unless Abingo::Experiment.exists?(test_name)
  62. lock_key = test_name.gsub(" ", "_")
  63. if Abingo.cache.exist?(lock_key)
  64. while Abingo.cache.exist?(lock_key)
  65. sleep(0.1)
  66. end
  67. break
  68. end
  69. Abingo.cache.write(lock_key, 1, :expires_in => 5.seconds)
  70. conversion_name = options[:conversion] || options[:conversion_name]
  71. Abingo::Experiment.start_experiment!(test_name, self.parse_alternatives(alternatives), conversion_name)
  72. Abingo.cache.delete(lock_key)
  73. end
  74. choice = self.find_alternative_for_user(test_name, alternatives)
  75. participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
  76. #Set this user to participate in this experiment, and increment participants count.
  77. if options[:multiple_participation] || !(participating_tests.include?(test_name))
  78. unless participating_tests.include?(test_name)
  79. participating_tests = participating_tests + [test_name]
  80. Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests)
  81. end
  82. Abingo::Alternative.score_participation(test_name)
  83. end
  84. if block_given?
  85. yield(choice)
  86. else
  87. choice
  88. end
  89. end
  90. #Scores conversions for tests.
  91. #test_name_or_array supports three types of input:
  92. #
  93. #A conversion name: scores a conversion for any test the user is participating in which
  94. # is listening to the specified conversion.
  95. #
  96. #A test name: scores a conversion for the named test if the user is participating in it.
  97. #
  98. #An array of either of the above: for each element of the array, process as above.
  99. #
  100. #nil: score a conversion for every test the u
  101. def Abingo.bingo!(name = nil, options = {})
  102. if name.kind_of? Array
  103. name.map do |single_test|
  104. self.bingo!(single_test, options)
  105. end
  106. else
  107. if name.nil?
  108. #Score all participating tests
  109. participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
  110. participating_tests.each do |participating_test|
  111. self.bingo!(participating_test, options)
  112. end
  113. else #Could be a test name or conversion name.
  114. conversion_name = name.gsub(" ", "_")
  115. tests_listening_to_conversion = Abingo.cache.read("Abingo::tests_listening_to_conversion#{conversion_name}")
  116. if tests_listening_to_conversion
  117. if tests_listening_to_conversion.size > 1
  118. tests_listening_to_conversion.map do |individual_test|
  119. self.score_conversion!(individual_test.to_s)
  120. end
  121. elsif tests_listening_to_conversion.size == 1
  122. test_name_str = tests_listening_to_conversion.first.to_s
  123. self.score_conversion!(test_name_str)
  124. end
  125. else
  126. #No tests listening for this conversion. Assume it is just a test name.
  127. test_name_str = name.to_s
  128. self.score_conversion!(test_name_str)
  129. end
  130. end
  131. end
  132. end
  133. protected
  134. #For programmer convenience, we allow you to specify what the alternatives for
  135. #an experiment are in a few ways. Thus, we need to actually be able to handle
  136. #all of them. We fire this parser very infrequently (once per test, typically)
  137. #so it can be as complicated as we want.
  138. # Integer => a number 1 through N
  139. # Range => a number within the range
  140. # Array => an element of the array.
  141. # Hash => assumes a hash of something to int. We pick one of the
  142. # somethings, weighted accorded to the ints provided. e.g.
  143. # {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
  144. #
  145. #Alternatives are always represented internally as an array.
  146. def self.parse_alternatives(alternatives)
  147. if alternatives.kind_of? Array
  148. return alternatives
  149. elsif alternatives.kind_of? Integer
  150. return (1..alternatives).to_a
  151. elsif alternatives.kind_of? Range
  152. return alternatives.to_a
  153. elsif alternatives.kind_of? Hash
  154. alternatives_array = []
  155. alternatives.each do |key, value|
  156. if value.kind_of? Integer
  157. alternatives_array += [key] * value
  158. else
  159. raise "You gave a hash with #{key} => #{value} as an element. The value must be an integral weight."
  160. end
  161. end
  162. return alternatives_array
  163. else
  164. raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
  165. end
  166. end
  167. def self.retrieve_alternatives(test_name, alternatives)
  168. cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
  169. alternative_array = self.cache.fetch(cache_key) do
  170. self.parse_alternatives(alternatives)
  171. end
  172. alternative_array
  173. end
  174. def self.find_alternative_for_user(test_name, alternatives)
  175. alternatives_array = retrieve_alternatives(test_name, alternatives)
  176. alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
  177. end
  178. #Quickly determines what alternative to show a given user. Given a test name
  179. #and their identity, we hash them together (which, for MD5, provably introduces
  180. #enough entropy that we don't care) otherwise
  181. def self.modulo_choice(test_name, choices_count)
  182. Digest::MD5.hexdigest(Abingo.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
  183. end
  184. def self.score_conversion!(test_name)
  185. test_name.gsub!(" ", "_")
  186. participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
  187. if options[:assume_participation] || participating_tests.include?(test_name)
  188. cache_key = "Abingo::conversions(#{Abingo.identity},#{test_name}"
  189. if options[:multiple_conversions] || !Abingo.cache.read(cache_key)
  190. Abingo::Alternative.score_conversion(test_name)
  191. if Abingo.cache.exist?(cache_key)
  192. Abingo.cache.increment(cache_key)
  193. else
  194. Abingo.cache.write(cache_key, 1)
  195. end
  196. end
  197. end
  198. end
  199. end