├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE ├── Rakefile ├── Readme.rdoc ├── demo ├── faraday_consumer.rb ├── out_of_process_consumer.rb └── simple_messages.rb ├── lib ├── mixpanel-ruby.rb └── mixpanel-ruby │ ├── consumer.rb │ ├── error.rb │ ├── events.rb │ ├── groups.rb │ ├── people.rb │ ├── tracker.rb │ └── version.rb ├── mixpanel-ruby.gemspec └── spec ├── mixpanel-ruby ├── consumer_spec.rb ├── error_spec.rb ├── events_spec.rb ├── groups_spec.rb ├── people_spec.rb └── tracker_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | Gemfile.lock 3 | html 4 | mixpanel-ruby*.gem 5 | .bundle 6 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.6.0 4 | - 2.5.0 5 | - 2.3.0 6 | - 2.2.0 7 | - 2.1.0 8 | - 2.0.0 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012 Mixpanel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this work except in compliance with the License. 5 | You may obtain a copy of the License below, or at: 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | Apache License 16 | Version 2.0, January 2004 17 | http://www.apache.org/licenses/ 18 | 19 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 20 | 21 | 1. Definitions. 22 | 23 | "License" shall mean the terms and conditions for use, reproduction, 24 | and distribution as defined by Sections 1 through 9 of this document. 25 | 26 | "Licensor" shall mean the copyright owner or entity authorized by 27 | the copyright owner that is granting the License. 28 | 29 | "Legal Entity" shall mean the union of the acting entity and all 30 | other entities that control, are controlled by, or are under common 31 | control with that entity. For the purposes of this definition, 32 | "control" means (i) the power, direct or indirect, to cause the 33 | direction or management of such entity, whether by contract or 34 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 35 | outstanding shares, or (iii) beneficial ownership of such entity. 36 | 37 | "You" (or "Your") shall mean an individual or Legal Entity 38 | exercising permissions granted by this License. 39 | 40 | "Source" form shall mean the preferred form for making modifications, 41 | including but not limited to software source code, documentation 42 | source, and configuration files. 43 | 44 | "Object" form shall mean any form resulting from mechanical 45 | transformation or translation of a Source form, including but 46 | not limited to compiled object code, generated documentation, 47 | and conversions to other media types. 48 | 49 | "Work" shall mean the work of authorship, whether in Source or 50 | Object form, made available under the License, as indicated by a 51 | copyright notice that is included in or attached to the work 52 | (an example is provided in the Appendix below). 53 | 54 | "Derivative Works" shall mean any work, whether in Source or Object 55 | form, that is based on (or derived from) the Work and for which the 56 | editorial revisions, annotations, elaborations, or other modifications 57 | represent, as a whole, an original work of authorship. For the purposes 58 | of this License, Derivative Works shall not include works that remain 59 | separable from, or merely link (or bind by name) to the interfaces of, 60 | the Work and Derivative Works thereof. 61 | 62 | "Contribution" shall mean any work of authorship, including 63 | the original version of the Work and any modifications or additions 64 | to that Work or Derivative Works thereof, that is intentionally 65 | submitted to Licensor for inclusion in the Work by the copyright owner 66 | or by an individual or Legal Entity authorized to submit on behalf of 67 | the copyright owner. For the purposes of this definition, "submitted" 68 | means any form of electronic, verbal, or written communication sent 69 | to the Licensor or its representatives, including but not limited to 70 | communication on electronic mailing lists, source code control systems, 71 | and issue tracking systems that are managed by, or on behalf of, the 72 | Licensor for the purpose of discussing and improving the Work, but 73 | excluding communication that is conspicuously marked or otherwise 74 | designated in writing by the copyright owner as "Not a Contribution." 75 | 76 | "Contributor" shall mean Licensor and any individual or Legal Entity 77 | on behalf of whom a Contribution has been received by Licensor and 78 | subsequently incorporated within the Work. 79 | 80 | 2. Grant of Copyright License. Subject to the terms and conditions of 81 | this License, each Contributor hereby grants to You a perpetual, 82 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 83 | copyright license to reproduce, prepare Derivative Works of, 84 | publicly display, publicly perform, sublicense, and distribute the 85 | Work and such Derivative Works in Source or Object form. 86 | 87 | 3. Grant of Patent License. Subject to the terms and conditions of 88 | this License, each Contributor hereby grants to You a perpetual, 89 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 90 | (except as stated in this section) patent license to make, have made, 91 | use, offer to sell, sell, import, and otherwise transfer the Work, 92 | where such license applies only to those patent claims licensable 93 | by such Contributor that are necessarily infringed by their 94 | Contribution(s) alone or by combination of their Contribution(s) 95 | with the Work to which such Contribution(s) was submitted. If You 96 | institute patent litigation against any entity (including a 97 | cross-claim or counterclaim in a lawsuit) alleging that the Work 98 | or a Contribution incorporated within the Work constitutes direct 99 | or contributory patent infringement, then any patent licenses 100 | granted to You under this License for that Work shall terminate 101 | as of the date such litigation is filed. 102 | 103 | 4. Redistribution. You may reproduce and distribute copies of the 104 | Work or Derivative Works thereof in any medium, with or without 105 | modifications, and in Source or Object form, provided that You 106 | meet the following conditions: 107 | 108 | (a) You must give any other recipients of the Work or 109 | Derivative Works a copy of this License; and 110 | 111 | (b) You must cause any modified files to carry prominent notices 112 | stating that You changed the files; and 113 | 114 | (c) You must retain, in the Source form of any Derivative Works 115 | that You distribute, all copyright, patent, trademark, and 116 | attribution notices from the Source form of the Work, 117 | excluding those notices that do not pertain to any part of 118 | the Derivative Works; and 119 | 120 | (d) If the Work includes a "NOTICE" text file as part of its 121 | distribution, then any Derivative Works that You distribute must 122 | include a readable copy of the attribution notices contained 123 | within such NOTICE file, excluding those notices that do not 124 | pertain to any part of the Derivative Works, in at least one 125 | of the following places: within a NOTICE text file distributed 126 | as part of the Derivative Works; within the Source form or 127 | documentation, if provided along with the Derivative Works; or, 128 | within a display generated by the Derivative Works, if and 129 | wherever such third-party notices normally appear. The contents 130 | of the NOTICE file are for informational purposes only and 131 | do not modify the License. You may add Your own attribution 132 | notices within Derivative Works that You distribute, alongside 133 | or as an addendum to the NOTICE text from the Work, provided 134 | that such additional attribution notices cannot be construed 135 | as modifying the License. 136 | 137 | You may add Your own copyright statement to Your modifications and 138 | may provide additional or different license terms and conditions 139 | for use, reproduction, or distribution of Your modifications, or 140 | for any such Derivative Works as a whole, provided Your use, 141 | reproduction, and distribution of the Work otherwise complies with 142 | the conditions stated in this License. 143 | 144 | 5. Submission of Contributions. Unless You explicitly state otherwise, 145 | any Contribution intentionally submitted for inclusion in the Work 146 | by You to the Licensor shall be under the terms and conditions of 147 | this License, without any additional terms or conditions. 148 | Notwithstanding the above, nothing herein shall supersede or modify 149 | the terms of any separate license agreement you may have executed 150 | with Licensor regarding such Contributions. 151 | 152 | 6. Trademarks. This License does not grant permission to use the trade 153 | names, trademarks, service marks, or product names of the Licensor, 154 | except as required for reasonable and customary use in describing the 155 | origin of the Work and reproducing the content of the NOTICE file. 156 | 157 | 7. Disclaimer of Warranty. Unless required by applicable law or 158 | agreed to in writing, Licensor provides the Work (and each 159 | Contributor provides its Contributions) on an "AS IS" BASIS, 160 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 161 | implied, including, without limitation, any warranties or conditions 162 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 163 | PARTICULAR PURPOSE. You are solely responsible for determining the 164 | appropriateness of using or redistributing the Work and assume any 165 | risks associated with Your exercise of permissions under this License. 166 | 167 | 8. Limitation of Liability. In no event and under no legal theory, 168 | whether in tort (including negligence), contract, or otherwise, 169 | unless required by applicable law (such as deliberate and grossly 170 | negligent acts) or agreed to in writing, shall any Contributor be 171 | liable to You for damages, including any direct, indirect, special, 172 | incidental, or consequential damages of any character arising as a 173 | result of this License or out of the use or inability to use the 174 | Work (including but not limited to damages for loss of goodwill, 175 | work stoppage, computer failure or malfunction, or any and all 176 | other commercial damages or losses), even if such Contributor 177 | has been advised of the possibility of such damages. 178 | 179 | 9. Accepting Warranty or Additional Liability. While redistributing 180 | the Work or Derivative Works thereof, You may choose to offer, 181 | and charge a fee for, acceptance of support, warranty, indemnity, 182 | or other liability obligations and/or rights consistent with this 183 | License. However, in accepting such obligations, You may act only 184 | on Your own behalf and on Your sole responsibility, not on behalf 185 | of any other Contributor, and only if You agree to indemnify, 186 | defend, and hold each Contributor harmless for any liability 187 | incurred by, or claims asserted against, such Contributor by reason 188 | of your accepting any such warranty or additional liability. 189 | 190 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | require 'rdoc/task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) do |spec| 5 | spec.pattern = 'spec/**/*_spec.rb' 6 | end 7 | 8 | Rake::RDocTask.new do |rd| 9 | rd.main = "Readme.rdoc" 10 | rd.rdoc_files.include("Readme.rdoc", "lib/**/*.rb") 11 | end 12 | 13 | task :default => :spec 14 | -------------------------------------------------------------------------------- /Readme.rdoc: -------------------------------------------------------------------------------- 1 | = mixpanel-ruby: The official Mixpanel Ruby library 2 | 3 | mixpanel-ruby is a library for tracking events and sending \Mixpanel profile 4 | updates to \Mixpanel from your ruby applications. 5 | 6 | == Installation 7 | 8 | gem install mixpanel-ruby 9 | 10 | == Getting Started 11 | 12 | require 'mixpanel-ruby' 13 | 14 | tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 15 | 16 | # Track an event on behalf of user "User1" 17 | tracker.track('User1', 'A Mixpanel Event') 18 | 19 | # Send an update to User1's profile 20 | tracker.people.set('User1', { 21 | '$first_name' => 'David', 22 | '$last_name' => 'Bowie', 23 | 'Best Album' => 'The Rise and Fall of Ziggy Stardust and the Spiders from Mars' 24 | }) 25 | 26 | The primary class you will use to track events is Mixpanel::Tracker. An instance of 27 | Mixpanel::Tracker is enough to send events directly to \Mixpanel, and get you integrated 28 | right away. 29 | 30 | == Additional Information 31 | 32 | For more information please visit: 33 | 34 | * Our Ruby API Integration page[https://mixpanel.com/help/reference/ruby#introduction] 35 | * The usage demo[https://github.com/mixpanel/mixpanel-ruby/tree/master/demo] 36 | * The documentation[http://mixpanel.github.io/mixpanel-ruby/] 37 | 38 | The official Mixpanel gem is built with simplicity and broad applicability in 39 | mind, but there are also third party Ruby libraries that can work with the library 40 | to provide useful features in common situations, and support different development 41 | points of view. 42 | 43 | In particular, for Rails apps, the following projects are currently actively maintained: 44 | 45 | * MetaEvents[https://github.com/swiftype/meta_events] 46 | * Mengpaneel[https://github.com/DouweM/mengpaneel] 47 | 48 | == Changes 49 | 50 | == 2.2.2 51 | * Add Group Analytics support with Mixpanel::Groups 52 | 53 | == 2.2.1 54 | * Fix buffer clearing on partially successful writes in BufferedConsumer. 55 | 56 | == 2.2.0 57 | * Add Mixpanel::ErrorHandler to simplify custom error handling. 58 | * Modify Mixpanel::People#fix_property_dates to handle ActiveSupport::TimeWithZone. 59 | * Increase open and ssl timeouts from 2s to 10s. 60 | * Fix Doc inconsistancy: always pass token on Mixpanel::tracker.new. 61 | 62 | == 2.1.0 63 | * Add Mixpanel::Tracker#generate_tracking_url, which generates {pixel tracking urls}[https://mixpanel.com/docs/api-documentation/pixel-based-event-tracking]. 64 | * Rescue JSONErrors in the consumer and raise Mixpanel::ServerError in Mixpanel::Consumer#send!. 65 | * Make it clear how to import events with custom timestamp. 66 | * Update dependancies in gemspec 67 | 68 | == 2.0.1 69 | * Add Deprecated version of Mixpanel::BufferedConsumer#send 70 | 71 | == 2.0.0 72 | * Raise mixpanel server and connection errors in Mixpanel::Consumer. 73 | * All public methods in Mixpanel::Event, Mixpanel::People, and subsequently Mixpanel::Tracker 74 | rescue Mixpanel errors and return false in the case of an error, return true otherwise 75 | * Deprecate Mixpanel::Consumer#send, replace with Mixpanel::Consumer#send! 76 | * Require ruby version minimum of 2.0.0 77 | 78 | == 1.4.0 79 | * Allow unset to unset multiple properties 80 | 81 | == 1.3.0 82 | * Added Consumer#request method, demo with Faraday integration 83 | 84 | == 1.2.0 85 | * All objects with a "strftime" method will be formatted as dates in 86 | people updates. 87 | 88 | == 1.1.0 89 | * The default consumer now sends requests (and expects responses) in 90 | verbose, JSON mode, which may improve error reporting. 91 | 92 | === 1.0.2 93 | * Allow ip and optional_params arguments to be accepted by all 94 | Mixpanel::People methods (except #destroy_user) 95 | 96 | === 1.0.1 97 | * Compatibility with earlier versions of ruby. Library development will continue 98 | to target 1.9, so later versions may not be compatible with Ruby 1.8, but we 99 | love patches! 100 | 101 | === 1.0.0 102 | * tracker#import added 103 | * Change to internal tracking message format. Messages written 104 | by earlier versions of the library will not work with 1.0.0 consumer classes. 105 | * alias bugfixed 106 | * Fixes to tests to allow for different timezones 107 | * Support for optional/experimental people api properties in people calls 108 | -------------------------------------------------------------------------------- /demo/faraday_consumer.rb: -------------------------------------------------------------------------------- 1 | require 'mixpanel-ruby' 2 | require 'faraday' 3 | 4 | # The Mixpanel library's default consumer will use the standard 5 | # Net::HTTP library to communicate with servers, but you can extend 6 | # your consumers to use other libraries. This example sends data using 7 | # the Faraday library (so you'll need that library available to run it) 8 | 9 | class FaradayConsumer < Mixpanel::Consumer 10 | def request(endpoint, form_data) 11 | conn = ::Faraday.new(endpoint) 12 | response = conn.post(nil, form_data) 13 | [response.status, response.body] 14 | end 15 | end 16 | 17 | if __FILE__ == $0 18 | # Replace this with the token from your project settings 19 | DEMO_TOKEN = '072f77c15bd04a5d0044d3d76ced7fea' 20 | faraday_consumer = FaradayConsumer.new 21 | 22 | faraday_tracker = Mixpanel::Tracker.new(DEMO_TOKEN) do |type, message| 23 | faraday_consumer.send!(type, message) 24 | end 25 | faraday_tracker.track('ID', 'Event tracked through Faraday') 26 | 27 | # It's also easy to delegate from a BufferedConsumer to your custom 28 | # consumer. 29 | 30 | buffered_faraday_consumer = Mixpanel::BufferedConsumer.new do |type, message| 31 | faraday_consumer.send!(type, message) 32 | end 33 | 34 | buffered_faraday_tracker = Mixpanel::Tracker.new(DEMO_TOKEN) do |type, message| 35 | buffered_faraday_consumer.send!(type, message) 36 | end 37 | 38 | buffered_faraday_tracker.track('ID', 'Event tracked (buffered) through faraday') 39 | buffered_faraday_consumer.flush 40 | end 41 | -------------------------------------------------------------------------------- /demo/out_of_process_consumer.rb: -------------------------------------------------------------------------------- 1 | require 'mixpanel-ruby' 2 | require 'thread' 3 | require 'json' 4 | require 'securerandom' 5 | 6 | # As your application scales, it's likely you'll want to 7 | # to detect events in one place and send them somewhere 8 | # else. For example, you might write the events to a queue 9 | # to be consumed by another process. 10 | # 11 | # This demo shows how you might do things, using 12 | # the block constructor in Mixpanel to enqueue events, 13 | # and a MixpanelBufferedConsumer to send them to 14 | # Mixpanel 15 | 16 | # Mixpanel uses the Net::HTTP library, which by default 17 | # will not verify remote SSL certificates. In your app, 18 | # you'll need to call Mixpanel.config_http with the path 19 | # to your Certificate authority resources, or the library 20 | # won't verify the remote certificate identity. 21 | Mixpanel.config_http do |http| 22 | http.ca_path = '/etc/ssl/certs' 23 | http.ca_file = "/etc/ssl/certs/ca-certificates.crt" 24 | http.use_ssl = true 25 | http.verify_mode = OpenSSL::SSL::VERIFY_PEER 26 | end 27 | 28 | class OutOfProcessExample 29 | class << self 30 | def run(token, distinct_id) 31 | open('|-', 'w+') do |subprocess| 32 | if subprocess 33 | # This is the tracking process. Once we configure 34 | # The tracker to write to our subprocess, we can quickly 35 | # call #track without delaying our other work. 36 | mixpanel_tracker = Mixpanel::Tracker.new(token) do |*message| 37 | subprocess.write(message.to_json + "\n") 38 | end 39 | 40 | 100.times do |i| 41 | event = 'Tick' 42 | mixpanel_tracker.track(distinct_id, event, {'Tick Number' => i}) 43 | puts "tick #{i}" 44 | end 45 | 46 | else 47 | # This is the consumer process. In your applications, code 48 | # like this may end up in queue consumers or in a separate 49 | # thread. 50 | mixpanel_consumer = Mixpanel::BufferedConsumer.new 51 | begin 52 | $stdin.each_line do |line| 53 | message = JSON.load(line) 54 | type, content = message 55 | mixpanel_consumer.send!(type, content) 56 | end 57 | ensure 58 | mixpanel_consumer.flush 59 | end 60 | end 61 | end 62 | end # run 63 | end 64 | end 65 | 66 | if __FILE__ == $0 67 | # Replace this with the token from your project settings 68 | DEMO_TOKEN = '072f77c15bd04a5d0044d3d76ced7fea' 69 | run_id = SecureRandom.base64 70 | OutOfProcessExample.run(DEMO_TOKEN, run_id) 71 | end 72 | -------------------------------------------------------------------------------- /demo/simple_messages.rb: -------------------------------------------------------------------------------- 1 | require 'mixpanel-ruby' 2 | 3 | if __FILE__ == $0 4 | # Replace this with the token from your project settings 5 | DEMO_TOKEN = '072f77c15bd04a5d0044d3d76ced7fea' 6 | mixpanel_tracker = Mixpanel::Tracker.new(DEMO_TOKEN) 7 | mixpanel_tracker.track('ID', 'Script run') 8 | end 9 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby.rb: -------------------------------------------------------------------------------- 1 | require 'mixpanel-ruby/consumer.rb' 2 | require 'mixpanel-ruby/tracker.rb' 3 | require 'mixpanel-ruby/version.rb' 4 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby/consumer.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'json' 3 | require 'net/https' 4 | 5 | module Mixpanel 6 | @@init_http = nil 7 | 8 | # This method exists for backwards compatibility. The preferred 9 | # way to customize or configure the HTTP library of a consumer 10 | # is to override Consumer#request. 11 | # 12 | # Ruby's default SSL does not verify the server certificate. 13 | # To verify a certificate, or install a proxy, pass a block 14 | # to Mixpanel.config_http that configures the Net::HTTP object. 15 | # For example, if running in Ubuntu Linux, you can run 16 | # 17 | # Mixpanel.config_http do |http| 18 | # http.ca_path = '/etc/ssl/certs' 19 | # http.ca_file = '/etc/ssl/certs/ca-certificates.crt' 20 | # http.verify_mode = OpenSSL::SSL::VERIFY_PEER 21 | # end 22 | # 23 | # \Mixpanel Consumer and BufferedConsumer will call your block 24 | # to configure their connections 25 | def self.config_http(&block) 26 | @@init_http = block 27 | end 28 | 29 | # A Consumer receives messages from a Mixpanel::Tracker, and 30 | # sends them elsewhere- probably to Mixpanel's analytics services, 31 | # but can also enqueue them for later processing, log them to a 32 | # file, or do whatever else you might find useful. 33 | # 34 | # You can provide your own consumer to your Mixpanel::Trackers, 35 | # either by passing in an argument with a #send! method when you construct 36 | # the tracker, or just passing a block to Mixpanel::Tracker.new 37 | # 38 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) do |type, message| 39 | # # type will be one of :event, :profile_update or :import 40 | # @kestrel.set(ANALYTICS_QUEUE, [type, message].to_json) 41 | # end 42 | # 43 | # You can also instantiate the library consumers yourself, and use 44 | # them wherever you would like. For example, the working that 45 | # consumes the above queue might work like this: 46 | # 47 | # mixpanel = Mixpanel::Consumer 48 | # while true 49 | # message_json = @kestrel.get(ANALYTICS_QUEUE) 50 | # mixpanel.send!(*JSON.load(message_json)) 51 | # end 52 | # 53 | # Mixpanel::Consumer is the default consumer. It sends each message, 54 | # as the message is recieved, directly to Mixpanel. 55 | class Consumer 56 | 57 | # Create a Mixpanel::Consumer. If you provide endpoint arguments, 58 | # they will be used instead of the default Mixpanel endpoints. 59 | # This can be useful for proxying, debugging, or if you prefer 60 | # not to use SSL for your events. 61 | def initialize(events_endpoint=nil, 62 | update_endpoint=nil, 63 | groups_endpoint=nil, 64 | import_endpoint=nil) 65 | @events_endpoint = events_endpoint || 'https://api.mixpanel.com/track' 66 | @update_endpoint = update_endpoint || 'https://api.mixpanel.com/engage' 67 | @groups_endpoint = groups_endpoint || 'https://api.mixpanel.com/groups' 68 | @import_endpoint = import_endpoint || 'https://api.mixpanel.com/import' 69 | end 70 | 71 | # Send the given string message to Mixpanel. Type should be 72 | # one of :event, :profile_update or :import, which will determine the endpoint. 73 | # 74 | # Mixpanel::Consumer#send! sends messages to Mixpanel immediately on 75 | # each call. To reduce the overall bandwidth you use when communicating 76 | # with Mixpanel, you can also use Mixpanel::BufferedConsumer 77 | def send!(type, message) 78 | type = type.to_sym 79 | endpoint = { 80 | :event => @events_endpoint, 81 | :profile_update => @update_endpoint, 82 | :group_update => @groups_endpoint, 83 | :import => @import_endpoint, 84 | }[type] 85 | 86 | decoded_message = JSON.load(message) 87 | api_key = decoded_message["api_key"] 88 | data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '') 89 | 90 | form_data = {"data" => data, "verbose" => 1} 91 | form_data.merge!("api_key" => api_key) if api_key 92 | 93 | begin 94 | response_code, response_body = request(endpoint, form_data) 95 | rescue => e 96 | raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".") 97 | end 98 | 99 | result = {} 100 | if response_code.to_i == 200 101 | begin 102 | result = JSON.parse(response_body.to_s) 103 | rescue JSON::JSONError 104 | raise ServerError.new("Could not interpret Mixpanel server response: '#{response_body}'") 105 | end 106 | end 107 | 108 | if result['status'] != 1 109 | raise ServerError.new("Could not write to Mixpanel, server responded with #{response_code} returning: '#{response_body}'") 110 | end 111 | end 112 | 113 | # This method was deprecated in release 2.0.0, please use send! instead 114 | def send(type, message) 115 | warn '[DEPRECATION] send has been deprecated as of release 2.0.0, please use send! instead' 116 | send!(type, message) 117 | end 118 | 119 | # Request takes an endpoint HTTP or HTTPS url, and a Hash of data 120 | # to post to that url. It should return a pair of 121 | # 122 | # [response code, response body] 123 | # 124 | # as the result of the response. Response code should be nil if 125 | # the request never receives a response for some reason. 126 | def request(endpoint, form_data) 127 | uri = URI(endpoint) 128 | request = Net::HTTP::Post.new(uri.request_uri) 129 | request.set_form_data(form_data) 130 | 131 | client = Net::HTTP.new(uri.host, uri.port) 132 | client.use_ssl = true 133 | client.open_timeout = 10 134 | client.continue_timeout = 10 135 | client.read_timeout = 10 136 | client.ssl_timeout = 10 137 | 138 | Mixpanel.with_http(client) 139 | 140 | response = client.request(request) 141 | [response.code, response.body] 142 | end 143 | end 144 | 145 | # BufferedConsumer buffers messages in memory, and sends messages as 146 | # a batch. This can improve performance, but calls to #send! may 147 | # still block if the buffer is full. If you use this consumer, you 148 | # should call #flush when your application exits or the messages 149 | # remaining in the buffer will not be sent. 150 | # 151 | # To use a BufferedConsumer directly with a Mixpanel::Tracker, 152 | # instantiate your Tracker like this 153 | # 154 | # buffered_consumer = Mixpanel::BufferedConsumer.new 155 | # begin 156 | # buffered_tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) do |type, message| 157 | # buffered_consumer.send!(type, message) 158 | # end 159 | # # Do some tracking here 160 | # ... 161 | # ensure 162 | # buffered_consumer.flush 163 | # end 164 | # 165 | class BufferedConsumer 166 | MAX_LENGTH = 50 167 | 168 | # Create a Mixpanel::BufferedConsumer. If you provide endpoint arguments, 169 | # they will be used instead of the default Mixpanel endpoints. 170 | # This can be useful for proxying, debugging, or if you prefer 171 | # not to use SSL for your events. 172 | # 173 | # You can also change the preferred buffer size before the 174 | # consumer automatically sends its buffered events. The Mixpanel 175 | # endpoints have a limit of 50 events per HTTP request, but 176 | # you can lower the limit if your individual events are very large. 177 | # 178 | # By default, BufferedConsumer will use a standard Mixpanel 179 | # consumer to send the events once the buffer is full (or on calls 180 | # to #flush), but you can override this behavior by passing a 181 | # block to the constructor, in the same way you might pass a block 182 | # to the Mixpanel::Tracker constructor. If a block is passed to 183 | # the constructor, the *_endpoint constructor arguments are 184 | # ignored. 185 | def initialize(events_endpoint=nil, update_endpoint=nil, import_endpoint=nil, max_buffer_length=MAX_LENGTH, &block) 186 | @max_length = [max_buffer_length, MAX_LENGTH].min 187 | @buffers = { 188 | :event => [], 189 | :profile_update => [], 190 | } 191 | 192 | if block 193 | @sink = block 194 | else 195 | consumer = Consumer.new(events_endpoint, update_endpoint, import_endpoint) 196 | @sink = consumer.method(:send!) 197 | end 198 | end 199 | 200 | # Stores a message for Mixpanel in memory. When the buffer 201 | # hits a maximum length, the consumer will flush automatically. 202 | # Flushes are synchronous when they occur. 203 | # 204 | # Currently, only :event and :profile_update messages are buffered, 205 | # :import messages will be send immediately on call. 206 | def send!(type, message) 207 | type = type.to_sym 208 | 209 | if @buffers.has_key? type 210 | @buffers[type] << message 211 | flush_type(type) if @buffers[type].length >= @max_length 212 | else 213 | @sink.call(type, message) 214 | end 215 | end 216 | 217 | # This method was deprecated in release 2.0.0, please use send! instead 218 | def send(type, message) 219 | warn '[DEPRECATION] send has been deprecated as of release 2.0.0, please use send! instead' 220 | send!(type, message) 221 | end 222 | 223 | # Pushes all remaining messages in the buffer to Mixpanel. 224 | # You should call #flush before your application exits or 225 | # messages may not be sent. 226 | def flush 227 | @buffers.keys.each { |k| flush_type(k) } 228 | end 229 | 230 | private 231 | 232 | def flush_type(type) 233 | sent_messages = 0 234 | begin 235 | @buffers[type].each_slice(@max_length) do |chunk| 236 | data = chunk.map {|message| JSON.load(message)['data'] } 237 | @sink.call(type, {'data' => data}.to_json) 238 | sent_messages += chunk.length 239 | end 240 | rescue 241 | @buffers[type].slice!(0, sent_messages) 242 | raise 243 | end 244 | @buffers[type] = [] 245 | end 246 | end 247 | 248 | private 249 | 250 | def self.with_http(http) 251 | if @@init_http 252 | @@init_http.call(http) 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby/error.rb: -------------------------------------------------------------------------------- 1 | module Mixpanel 2 | 3 | # Mixpanel specific errors that are thrown in the gem. 4 | # In the default consumer we catch all errors and raise 5 | # Mixpanel specific errors that can be handled using a 6 | # custom error handler. 7 | class MixpanelError < StandardError 8 | end 9 | 10 | class ConnectionError < MixpanelError 11 | end 12 | 13 | class ServerError < MixpanelError 14 | end 15 | 16 | 17 | # The default behavior of the gem is to silence all errors 18 | # thrown in the consumer. If you wish to handle MixpanelErrors 19 | # yourself you can pass an instance of a class that extends 20 | # Mixpanel::ErrorHandler to Mixpanel::Tracker on initialize. 21 | # 22 | # require 'logger' 23 | # 24 | # class MyErrorHandler < Mixpanel::ErrorHandler 25 | # 26 | # def initialize 27 | # @logger = Logger.new('mylogfile.log') 28 | # @logger.level = Logger::ERROR 29 | # end 30 | # 31 | # def handle(error) 32 | # logger.error "#{error.inspect}\n Backtrace: #{error.backtrace}" 33 | # end 34 | # 35 | # end 36 | # 37 | # my_error_handler = MyErrorHandler.new 38 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, my_error_handler) 39 | class ErrorHandler 40 | 41 | # Override #handle to customize error handling 42 | def handle(error) 43 | false 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby/events.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | 3 | require 'mixpanel-ruby/consumer' 4 | require 'mixpanel-ruby/error' 5 | 6 | module Mixpanel 7 | 8 | # Handles formatting Mixpanel event tracking messages 9 | # and sending them to the consumer. Mixpanel::Tracker 10 | # is a subclass of this class, and the best way to 11 | # track events is to instantiate a Mixpanel::Tracker 12 | # 13 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) # Has all of the methods of Mixpanel::Event 14 | # tracker.track(...) 15 | # 16 | class Events 17 | 18 | # You likely won't need to instantiate an instance of 19 | # Mixpanel::Events directly. The best way to get an instance 20 | # is to use Mixpanel::Tracker 21 | # 22 | # # tracker has all of the methods of Mixpanel::Events 23 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 24 | # 25 | def initialize(token, error_handler=nil, &block) 26 | @token = token 27 | @error_handler = error_handler || ErrorHandler.new 28 | 29 | if block 30 | @sink = block 31 | else 32 | consumer = Consumer.new 33 | @sink = consumer.method(:send!) 34 | end 35 | end 36 | 37 | # Notes that an event has occurred, along with a distinct_id 38 | # representing the source of that event (for example, a user id), 39 | # an event name describing the event and a set of properties 40 | # describing that event. Properties are provided as a Hash with 41 | # string keys and strings, numbers or booleans as values. 42 | # 43 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 44 | # 45 | # # Track that user "12345"'s credit card was declined 46 | # tracker.track("12345", "Credit Card Declined") 47 | # 48 | # # Properties describe the circumstances of the event, 49 | # # or aspects of the source or user associated with the event 50 | # tracker.track("12345", "Welcome Email Sent", { 51 | # 'Email Template' => 'Pretty Pink Welcome', 52 | # 'User Sign-up Cohort' => 'July 2013' 53 | # }) 54 | def track(distinct_id, event, properties={}, ip=nil) 55 | properties = { 56 | 'distinct_id' => distinct_id, 57 | 'token' => @token, 58 | 'time' => Time.now.to_f, 59 | 'mp_lib' => 'ruby', 60 | '$lib_version' => Mixpanel::VERSION, 61 | }.merge(properties) 62 | properties['ip'] = ip if ip 63 | 64 | data = { 65 | 'event' => event, 66 | 'properties' => properties, 67 | } 68 | 69 | message = {'data' => data} 70 | 71 | ret = true 72 | begin 73 | @sink.call(:event, message.to_json) 74 | rescue MixpanelError => e 75 | @error_handler.handle(e) 76 | ret = false 77 | end 78 | 79 | ret 80 | end 81 | 82 | # Imports an event that has occurred in the past, along with a distinct_id 83 | # representing the source of that event (for example, a user id), 84 | # an event name describing the event and a set of properties 85 | # describing that event. Properties are provided as a Hash with 86 | # string keys and strings, numbers or booleans as values. By default, 87 | # we pass the time of the method call as the time the event occured, if you 88 | # wish to override this pass a timestamp in the properties hash. 89 | # 90 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 91 | # 92 | # # Track that user "12345"'s credit card was declined 93 | # tracker.import("API_KEY", "12345", "Credit Card Declined") 94 | # 95 | # # Properties describe the circumstances of the event, 96 | # # or aspects of the source or user associated with the event 97 | # tracker.import("API_KEY", "12345", "Welcome Email Sent", { 98 | # 'Email Template' => 'Pretty Pink Welcome', 99 | # 'User Sign-up Cohort' => 'July 2013', 100 | # 'time' => 1369353600, 101 | # }) 102 | def import(api_key, distinct_id, event, properties={}, ip=nil) 103 | properties = { 104 | 'distinct_id' => distinct_id, 105 | 'token' => @token, 106 | 'time' => Time.now.to_f, 107 | 'mp_lib' => 'ruby', 108 | '$lib_version' => Mixpanel::VERSION, 109 | }.merge(properties) 110 | properties['ip'] = ip if ip 111 | 112 | data = { 113 | 'event' => event, 114 | 'properties' => properties, 115 | } 116 | 117 | message = { 118 | 'data' => data, 119 | 'api_key' => api_key, 120 | } 121 | 122 | ret = true 123 | begin 124 | @sink.call(:import, message.to_json) 125 | rescue MixpanelError => e 126 | @error_handler.handle(e) 127 | ret = false 128 | end 129 | 130 | ret 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby/groups.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'json' 3 | require 'time' 4 | 5 | require 'mixpanel-ruby/consumer' 6 | require 'mixpanel-ruby/error' 7 | 8 | module Mixpanel 9 | 10 | # Handles formatting Mixpanel group updates and 11 | # sending them to the consumer. You will rarely need 12 | # to instantiate this class directly- to send 13 | # group updates, use Mixpanel::Tracker#groups 14 | # 15 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 16 | # tracker.groups.set(...) or .set_once(..), or .delete(...) etc. 17 | class Groups 18 | 19 | # You likely won't need to instantiate instances of Mixpanel::Groups 20 | # directly. The best way to get an instance of Mixpanel::Groups is 21 | # 22 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 23 | # tracker.groups # An instance of Mixpanel::Groups 24 | # 25 | def initialize(token, error_handler=nil, &block) 26 | @token = token 27 | @error_handler = error_handler || ErrorHandler.new 28 | 29 | if block 30 | @sink = block 31 | else 32 | consumer = Consumer.new 33 | @sink = consumer.method(:send!) 34 | end 35 | end 36 | 37 | # Sets properties on a group record. Takes a Hash with string 38 | # keys, and values that are strings, numbers, booleans, or 39 | # DateTimes 40 | # 41 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 42 | # # Sets properties on group with id "1234" 43 | # tracker.groups.set("GROUP KEY", "1234", { 44 | # 'company' => 'Acme', 45 | # 'plan' => 'Premium', 46 | # 'Sign-Up Date' => DateTime.now 47 | # }); 48 | # 49 | # If you provide an ip argument, \Mixpanel will use that 50 | # ip address for geolocation (rather than the ip of your server) 51 | def set(group_key, group_id, properties, ip=nil, optional_params={}) 52 | properties = fix_property_dates(properties) 53 | message = { 54 | '$group_key' => group_key, 55 | '$group_id' => group_id, 56 | '$set' => properties, 57 | }.merge(optional_params) 58 | message['$ip'] = ip if ip 59 | 60 | update(message) 61 | end 62 | 63 | # set_once works just like #set, but will only change the 64 | # value of properties if they are not already present 65 | # in the group. That means you can call set_once many times 66 | # without changing an original value. 67 | # 68 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 69 | # tracker.groups.set_once("GROUP KEY", "1234", { 70 | # 'First Login Date': DateTime.now 71 | # }); 72 | # 73 | def set_once(group_key, group_id, properties, ip=nil, optional_params={}) 74 | properties = fix_property_dates(properties) 75 | message = { 76 | '$group_key' => group_key, 77 | '$group_id' => group_id, 78 | '$set_once' => properties, 79 | }.merge(optional_params) 80 | message['$ip'] = ip if ip 81 | 82 | update(message) 83 | end 84 | 85 | # Removes a specific value in a list property 86 | # 87 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 88 | # 89 | # # removes "socks" from the "Items purchased" list property 90 | # # for the specified group 91 | # tracker.groups.remove("GROUP KEY", "1234", { 'Items purchased' => 'socks' }) 92 | # 93 | def remove(group_key, group_id, properties, ip=nil, optional_params={}) 94 | properties = fix_property_dates(properties) 95 | message = { 96 | '$group_key' => group_key, 97 | '$group_id' => group_id, 98 | '$remove' => properties, 99 | }.merge(optional_params) 100 | message['$ip'] = ip if ip 101 | 102 | update(message) 103 | end 104 | 105 | # Set union on list valued properties. 106 | # Associates a list containing all elements of a given list, 107 | # and all elements currently in a list associated with the given 108 | # property. After a union, every element in the list associated 109 | # with a property will be unique. 110 | # 111 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 112 | # tracker.groups.union("GROUP KEY", "1234", { 113 | # 'Levels Completed' => ['Suffragette City'] 114 | # }); 115 | # 116 | def union(group_key, group_id, properties, ip=nil, optional_params={}) 117 | properties = fix_property_dates(properties) 118 | message = { 119 | '$group_key' => group_key, 120 | '$group_id' => group_id, 121 | '$union' => properties, 122 | }.merge(optional_params) 123 | message['$ip'] = ip if ip 124 | 125 | update(message) 126 | end 127 | 128 | # Removes properties and their values from a group. 129 | # 130 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 131 | # 132 | # # removes a single property and its value from a group 133 | # tracker.groups.unset("GROUP KEY", "1234", "Overdue Since") 134 | # 135 | # # removes multiple properties and their values from a group 136 | # tracker.groups.unset("GROUP KEY", 137 | # "1234", 138 | # ["Overdue Since", "Paid Date"]) 139 | # 140 | def unset(group_key, group_id, properties, ip=nil, optional_params={}) 141 | properties = [properties] unless properties.is_a?(Array) 142 | message = { 143 | '$group_key' => group_key, 144 | '$group_id' => group_id, 145 | '$unset' => properties, 146 | }.merge(optional_params) 147 | message['$ip'] = ip if ip 148 | 149 | update(message) 150 | end 151 | 152 | # Permanently delete a group from \Mixpanel groups analytics (all group 153 | # properties on events stay) 154 | def delete_group(group_key, group_id, optional_params={}) 155 | update({ 156 | '$group_key' => group_key, 157 | '$group_id' => group_id, 158 | '$delete' => '', 159 | }.merge(optional_params)) 160 | end 161 | 162 | # Send a generic update to \Mixpanel groups analytics. 163 | # Caller is responsible for formatting the update message, as 164 | # documented in the \Mixpanel HTTP specification, and passing 165 | # the message as a dict to #update. This 166 | # method might be useful if you want to use very new 167 | # or experimental features of groups analytics from Ruby 168 | # The \Mixpanel HTTP tracking API is documented at 169 | # https://mixpanel.com/help/reference/http 170 | def update(message) 171 | data = { 172 | '$token' => @token, 173 | '$time' => Time.now.to_f, 174 | }.merge(message) 175 | 176 | message = {'data' => data} 177 | 178 | ret = true 179 | begin 180 | @sink.call(:group_update, message.to_json) 181 | rescue MixpanelError => e 182 | @error_handler.handle(e) 183 | ret = false 184 | end 185 | 186 | ret 187 | end 188 | 189 | private 190 | 191 | def fix_property_dates(properties) 192 | properties.inject({}) do |ret, (key, value)| 193 | value = value.respond_to?(:new_offset) ? value.new_offset('0') : value 194 | value = value.respond_to?(:utc) ? value.utc : value # Handle ActiveSupport::TimeWithZone 195 | 196 | ret[key] = value.respond_to?(:strftime) ? value.strftime('%Y-%m-%dT%H:%M:%S') : value 197 | ret 198 | end 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby/people.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'json' 3 | require 'time' 4 | 5 | require 'mixpanel-ruby/consumer' 6 | require 'mixpanel-ruby/error' 7 | 8 | module Mixpanel 9 | 10 | # Handles formatting Mixpanel profile updates and 11 | # sending them to the consumer. You will rarely need 12 | # to instantiate this class directly- to send 13 | # profile updates, use Mixpanel::Tracker#people 14 | # 15 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 16 | # tracker.people.set(...) # Or .append(..), or track_charge(...) etc. 17 | class People 18 | 19 | # You likely won't need to instantiate instances of Mixpanel::People 20 | # directly. The best way to get an instance of Mixpanel::People is 21 | # 22 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 23 | # tracker.people # An instance of Mixpanel::People 24 | # 25 | def initialize(token, error_handler=nil, &block) 26 | @token = token 27 | @error_handler = error_handler || ErrorHandler.new 28 | 29 | if block 30 | @sink = block 31 | else 32 | consumer = Consumer.new 33 | @sink = consumer.method(:send!) 34 | end 35 | end 36 | 37 | # Sets properties on a user record. Takes a Hash with string 38 | # keys, and values that are strings, numbers, booleans, or 39 | # DateTimes 40 | # 41 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 42 | # # Sets properties on profile with id "1234" 43 | # tracker.people.set("1234", { 44 | # 'company' => 'Acme', 45 | # 'plan' => 'Premium', 46 | # 'Sign-Up Date' => DateTime.now 47 | # }); 48 | # 49 | # If you provide an ip argument, \Mixpanel will use that 50 | # ip address for geolocation (rather than the ip of your server) 51 | def set(distinct_id, properties, ip=nil, optional_params={}) 52 | properties = fix_property_dates(properties) 53 | message = { 54 | '$distinct_id' => distinct_id, 55 | '$set' => properties, 56 | }.merge(optional_params) 57 | message['$ip'] = ip if ip 58 | 59 | update(message) 60 | end 61 | 62 | # set_once works just like #set, but will only change the 63 | # value of properties if they are not already present 64 | # in the profile. That means you can call set_once many times 65 | # without changing an original value. 66 | # 67 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 68 | # tracker.people.set_once("12345", { 69 | # 'First Login Date': DateTime.now 70 | # }); 71 | # 72 | def set_once(distinct_id, properties, ip=nil, optional_params={}) 73 | properties = fix_property_dates(properties) 74 | message = { 75 | '$distinct_id' => distinct_id, 76 | '$set_once' => properties, 77 | }.merge(optional_params) 78 | message['$ip'] = ip if ip 79 | 80 | update(message) 81 | end 82 | 83 | # Changes the value of properties by a numeric amount. Takes a 84 | # hash with string keys and numeric properties. \Mixpanel will add 85 | # the given amount to whatever value is currently assigned to the 86 | # property. If no property exists with a given name, the value 87 | # will be added to zero. 88 | # 89 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 90 | # tracker.people.increment("12345", { 91 | # 'Coins Spent' => 7, 92 | # 'Coins Earned' => -7, # Use a negative number to subtract 93 | # }); 94 | # 95 | def increment(distinct_id, properties, ip=nil, optional_params={}) 96 | properties = fix_property_dates(properties) 97 | message = { 98 | '$distinct_id' => distinct_id, 99 | '$add' => properties, 100 | }.merge(optional_params) 101 | message['$ip'] = ip if ip 102 | 103 | update(message) 104 | end 105 | 106 | # Convenience method- increases the value of a numeric property 107 | # by one. Calling #plus_one(distinct_id, property_name) is the same as calling 108 | # #increment(distinct_id, {property_name => 1}) 109 | # 110 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 111 | # tracker.people.plus_one("12345", "Albums Released") 112 | # 113 | def plus_one(distinct_id, property_name, ip=nil, optional_params={}) 114 | increment(distinct_id, {property_name => 1}, ip, optional_params) 115 | end 116 | 117 | # Appends a values to the end of list-valued properties. 118 | # If the given properties don't exist, a new list-valued 119 | # property will be created. 120 | # 121 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 122 | # tracker.people.append("12345", { 123 | # 'Login Dates' => DateTime.now, 124 | # 'Alter Ego Names' => 'Ziggy Stardust' 125 | # }); 126 | # 127 | def append(distinct_id, properties, ip=nil, optional_params={}) 128 | properties = fix_property_dates(properties) 129 | message = { 130 | '$distinct_id' => distinct_id, 131 | '$append' => properties, 132 | }.merge(optional_params) 133 | message['$ip'] = ip if ip 134 | 135 | update(message) 136 | end 137 | 138 | # Set union on list valued properties. 139 | # Associates a list containing all elements of a given list, 140 | # and all elements currently in a list associated with the given 141 | # property. After a union, every element in the list associated 142 | # with a property will be unique. 143 | # 144 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 145 | # tracker.people.union("12345", { 146 | # 'Levels Completed' => ['Suffragette City'] 147 | # }); 148 | # 149 | def union(distinct_id, properties, ip=nil, optional_params={}) 150 | properties = fix_property_dates(properties) 151 | message = { 152 | '$distinct_id' => distinct_id, 153 | '$union' => properties, 154 | }.merge(optional_params) 155 | message['$ip'] = ip if ip 156 | 157 | update(message) 158 | end 159 | 160 | # Removes properties and their values from a profile. 161 | # 162 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 163 | # 164 | # # removes a single property and its value from a profile 165 | # tracker.people.unset("12345", "Overdue Since") 166 | # 167 | # # removes multiple properties and their values from a profile 168 | # tracker.people.unset("12345", ["Overdue Since", "Paid Date"]) 169 | # 170 | def unset(distinct_id, properties, ip=nil, optional_params={}) 171 | properties = [properties] unless properties.is_a?(Array) 172 | message = { 173 | '$distinct_id' => distinct_id, 174 | '$unset' => properties, 175 | }.merge(optional_params) 176 | message['$ip'] = ip if ip 177 | 178 | update(message) 179 | end 180 | 181 | # Records a payment to you to a profile. Charges recorded with 182 | # #track_charge will appear in the \Mixpanel revenue report. 183 | # 184 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 185 | # 186 | # # records a charge of $25.32 from user 12345 187 | # tracker.people.track_charge("12345", 25.32) 188 | # 189 | # # records a charge of $30.50 on the 2nd of January, 190 | # mixpanel.people.track_charge("12345", 30.50, { 191 | # '$time' => DateTime.parse("Jan 2 2013") 192 | # }) 193 | # 194 | def track_charge(distinct_id, amount, properties={}, ip=nil, optional_params={}) 195 | properties = fix_property_dates(properties) 196 | charge_properties = properties.merge({'$amount' => amount}) 197 | append(distinct_id, {'$transactions' => charge_properties}, ip, optional_params) 198 | end 199 | 200 | # Clear all charges from a \Mixpanel people profile 201 | def clear_charges(distinct_id, ip=nil, optional_params={}) 202 | unset(distinct_id, '$transactions', ip, optional_params) 203 | end 204 | 205 | # Permanently delete a profile from \Mixpanel people analytics 206 | # To delete a user and ignore alias pass into optional params 207 | # {"$ignore_alias"=>true} 208 | def delete_user(distinct_id, optional_params={}) 209 | update({ 210 | '$distinct_id' => distinct_id, 211 | '$delete' => '', 212 | }.merge(optional_params)) 213 | end 214 | 215 | # Send a generic update to \Mixpanel people analytics. 216 | # Caller is responsible for formatting the update message, as 217 | # documented in the \Mixpanel HTTP specification, and passing 218 | # the message as a dict to #update. This 219 | # method might be useful if you want to use very new 220 | # or experimental features of people analytics from Ruby 221 | # The \Mixpanel HTTP tracking API is documented at 222 | # https://mixpanel.com/help/reference/http 223 | def update(message) 224 | data = { 225 | '$token' => @token, 226 | '$time' => Time.now.to_f, 227 | }.merge(message) 228 | 229 | message = {'data' => data} 230 | 231 | ret = true 232 | begin 233 | @sink.call(:profile_update, message.to_json) 234 | rescue MixpanelError => e 235 | @error_handler.handle(e) 236 | ret = false 237 | end 238 | 239 | ret 240 | end 241 | 242 | private 243 | 244 | def fix_property_dates(properties) 245 | properties.inject({}) do |ret, (key, value)| 246 | value = value.respond_to?(:new_offset) ? value.new_offset('0') : value 247 | value = value.respond_to?(:utc) ? value.utc : value # Handle ActiveSupport::TimeWithZone 248 | 249 | ret[key] = value.respond_to?(:strftime) ? value.strftime('%Y-%m-%dT%H:%M:%S') : value 250 | ret 251 | end 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby/tracker.rb: -------------------------------------------------------------------------------- 1 | require 'mixpanel-ruby/events.rb' 2 | require 'mixpanel-ruby/people.rb' 3 | require 'mixpanel-ruby/groups.rb' 4 | 5 | module Mixpanel 6 | # Use Mixpanel::Tracker to track events and profile updates in your application. 7 | # To track an event, call 8 | # 9 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 10 | # Mixpanel::Tracker.track(a_distinct_id, an_event_name, {properties}) 11 | # 12 | # To send people updates, call 13 | # 14 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 15 | # tracker.people.set(a_distinct_id, {properties}) 16 | # 17 | # To send groups updates, call 18 | # 19 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 20 | # tracker.groups.set(group_key, group_id, {properties}) 21 | # 22 | # You can find your project token in the settings dialog for your 23 | # project, inside of the Mixpanel web application. 24 | # 25 | # Mixpanel::Tracker is a subclass of Mixpanel::Events, and exposes 26 | # an instance of Mixpanel::People as Tracker#people 27 | # and an instance of Mixpanel::Groups as Tracker#groups 28 | class Tracker < Events 29 | # An instance of Mixpanel::People. Use this to 30 | # send profile updates 31 | attr_reader :people 32 | 33 | # An instance of Mixpanel::Groups. Use this to send groups updates 34 | attr_reader :groups 35 | 36 | # Takes your Mixpanel project token, as a string. 37 | # 38 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 39 | # 40 | # By default, the tracker will send an message to Mixpanel 41 | # synchronously with each call, using an instance of Mixpanel::Consumer. 42 | # 43 | # You can also provide a block to the constructor 44 | # to specify particular consumer behaviors (for 45 | # example, if you wanted to write your messages to 46 | # a queue instead of sending them directly to Mixpanel) 47 | # 48 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) do |type, message| 49 | # @kestrel.set(MY_MIXPANEL_QUEUE, [type,message].to_json) 50 | # end 51 | # 52 | # If a block is provided, it is passed a type (one of :event or :profile_update) 53 | # and a string message. This same format is accepted by Mixpanel::Consumer#send! 54 | # and Mixpanel::BufferedConsumer#send! 55 | def initialize(token, error_handler=nil, &block) 56 | super(token, error_handler, &block) 57 | @token = token 58 | @people = People.new(token, error_handler, &block) 59 | @groups = Groups.new(token, error_handler, &block) 60 | end 61 | 62 | # A call to #track is a report that an event has occurred. #track 63 | # takes a distinct_id representing the source of that event (for 64 | # example, a user id), an event name describing the event, and a 65 | # set of properties describing that event. Properties are provided 66 | # as a Hash with string keys and strings, numbers or booleans as 67 | # values. 68 | # 69 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 70 | # 71 | # # Track that user "12345"'s credit card was declined 72 | # tracker.track("12345", "Credit Card Declined") 73 | # 74 | # # Properties describe the circumstances of the event, 75 | # # or aspects of the source or user associated with the event 76 | # tracker.track("12345", "Welcome Email Sent", { 77 | # 'Email Template' => 'Pretty Pink Welcome', 78 | # 'User Sign-up Cohort' => 'July 2013' 79 | # }) 80 | def track(distinct_id, event, properties={}, ip=nil) 81 | # This is here strictly to allow rdoc to include the relevant 82 | # documentation 83 | super 84 | end 85 | 86 | # A call to #import is to import an event occurred in the past. #import 87 | # takes a distinct_id representing the source of that event (for 88 | # example, a user id), an event name describing the event, and a 89 | # set of properties describing that event. Properties are provided 90 | # as a Hash with string keys and strings, numbers or booleans as 91 | # values. 92 | # 93 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 94 | # 95 | # # Import event that user "12345"'s credit card was declined 96 | # tracker.import("API_KEY", "12345", "Credit Card Declined", { 97 | # 'time' => 1310111365 98 | # }) 99 | # 100 | # # Properties describe the circumstances of the event, 101 | # # or aspects of the source or user associated with the event 102 | # tracker.import("API_KEY", "12345", "Welcome Email Sent", { 103 | # 'Email Template' => 'Pretty Pink Welcome', 104 | # 'User Sign-up Cohort' => 'July 2013', 105 | # 'time' => 1310111365 106 | # }) 107 | def import(api_key, distinct_id, event, properties={}, ip=nil) 108 | # This is here strictly to allow rdoc to include the relevant 109 | # documentation 110 | super 111 | end 112 | 113 | # Creates a distinct_id alias. \Events and updates with an alias 114 | # will be considered by mixpanel to have the same source, and 115 | # refer to the same profile. 116 | # 117 | # Multiple aliases can map to the same real_id, once a real_id is 118 | # used to track events or send updates, it should never be used as 119 | # an alias itself. 120 | # 121 | # Alias requests are always sent synchronously, directly to 122 | # the \Mixpanel service, regardless of how the tracker is configured. 123 | def alias(alias_id, real_id, events_endpoint=nil) 124 | consumer = Mixpanel::Consumer.new(events_endpoint) 125 | data = { 126 | 'event' => '$create_alias', 127 | 'properties' => { 128 | 'distinct_id' => real_id, 129 | 'alias' => alias_id, 130 | 'token' => @token, 131 | } 132 | } 133 | 134 | message = {'data' => data} 135 | 136 | ret = true 137 | begin 138 | consumer.send!(:event, message.to_json) 139 | rescue MixpanelError => e 140 | @error_handler.handle(e) 141 | ret = false 142 | end 143 | 144 | ret 145 | end 146 | 147 | # A call to #generate_tracking_url will return a formatted url for 148 | # pixel based tracking. #generate_tracking_url takes a distinct_id 149 | # representing the source of that event (for example, a user id), 150 | # an event name describing the event, and a set of properties describing 151 | # that event. Properties are provided as a Hash with string keys and 152 | # strings, numbers or booleans as values. For more information, please see: 153 | # https://mixpanel.com/docs/api-documentation/pixel-based-event-tracking 154 | # 155 | # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) 156 | # 157 | # # generate pixel tracking url in order to track that user 158 | # # "12345"'s credit card was declined 159 | # url = tracker.generate_tracking_url("12345", "Credit Card Declined", { 160 | # 'time' => 1310111365 161 | # }) 162 | # 163 | # url == 'https://api.mixpanel.com/track/?data=[BASE_64_JSON_EVENT]&ip=1&img=1' 164 | def generate_tracking_url(distinct_id, event, properties={}, endpoint=nil) 165 | properties = { 166 | 'distinct_id' => distinct_id, 167 | 'token' => @token, 168 | 'time' => Time.now.to_f, 169 | 'mp_lib' => 'ruby', 170 | '$lib_version' => Mixpanel::VERSION, 171 | }.merge(properties) 172 | 173 | raw_data = { 174 | 'event' => event, 175 | 'properties' => properties, 176 | } 177 | 178 | endpoint = endpoint || 'https://api.mixpanel.com/track/' 179 | data = Base64.urlsafe_encode64(raw_data.to_json) 180 | 181 | "#{endpoint}?data=#{data}&ip=1&img=1" 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/mixpanel-ruby/version.rb: -------------------------------------------------------------------------------- 1 | module Mixpanel 2 | VERSION = '2.3.0' 3 | end 4 | -------------------------------------------------------------------------------- /mixpanel-ruby.gemspec: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'lib/mixpanel-ruby/version.rb') 2 | 3 | spec = Gem::Specification.new do |spec| 4 | spec.name = 'mixpanel-ruby' 5 | spec.version = Mixpanel::VERSION 6 | spec.files = Dir.glob(`git ls-files`.split("\n")) 7 | spec.require_paths = ['lib'] 8 | spec.summary = 'Official Mixpanel tracking library for ruby' 9 | spec.description = 'The official Mixpanel tracking library for ruby' 10 | spec.authors = [ 'Mixpanel' ] 11 | spec.email = 'support@mixpanel.com' 12 | spec.homepage = 'https://mixpanel.com/help/reference/ruby' 13 | spec.license = 'Apache License 2.0' 14 | 15 | spec.required_ruby_version = '>= 2.0.0' 16 | 17 | spec.add_development_dependency 'activesupport', '~> 4.0' 18 | spec.add_development_dependency 'rake', '~> 0' 19 | spec.add_development_dependency 'rspec', '~> 3.0' 20 | spec.add_development_dependency 'webmock', '~> 1.18' 21 | end 22 | -------------------------------------------------------------------------------- /spec/mixpanel-ruby/consumer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'spec_helper' 3 | require 'webmock' 4 | 5 | require 'mixpanel-ruby/consumer' 6 | 7 | describe Mixpanel::Consumer do 8 | before { WebMock.reset! } 9 | 10 | shared_examples_for 'consumer' do 11 | it 'should send a request to api.mixpanel.com/track on events' do 12 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 13 | subject.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json) 14 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 15 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' }) 16 | end 17 | 18 | it 'should send a request to api.mixpanel.com/people on profile updates' do 19 | stub_request(:any, 'https://api.mixpanel.com/engage').to_return({:body => '{"status": 1, "error": null}'}) 20 | subject.send!(:profile_update, {'data' => 'TEST EVENT MESSAGE'}.to_json) 21 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/engage'). 22 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' }) 23 | end 24 | 25 | it 'should send a request to api.mixpanel.com/groups on groups updates' do 26 | stub_request(:any, 'https://api.mixpanel.com/groups').to_return({:body => '{"status": 1, "error": null}'}) 27 | subject.send!(:group_update, {'data' => 'TEST EVENT MESSAGE'}.to_json) 28 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/groups'). 29 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' }) 30 | end 31 | 32 | it 'should send a request to api.mixpanel.com/import on event imports' do 33 | stub_request(:any, 'https://api.mixpanel.com/import').to_return({:body => '{"status": 1, "error": null}'}) 34 | subject.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'api_key' => 'API_KEY','verbose' => '1' }.to_json) 35 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). 36 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'api_key' => 'API_KEY', 'verbose' => '1' }) 37 | end 38 | 39 | it 'should encode long messages without newlines' do 40 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 41 | subject.send!(:event, {'data' => 'BASE64-ENCODED VERSION OF BIN. THIS METHOD COMPLIES WITH RFC 2045. LINE FEEDS ARE ADDED TO EVERY 60 ENCODED CHARACTORS. IN RUBY 1.8 WE NEED TO JUST CALL ENCODE64 AND REMOVE THE LINE FEEDS, IN RUBY 1.9 WE CALL STRIC_ENCODED64 METHOD INSTEAD'}.to_json) 42 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 43 | with(:body => {'data' => 'IkJBU0U2NC1FTkNPREVEIFZFUlNJT04gT0YgQklOLiBUSElTIE1FVEhPRCBDT01QTElFUyBXSVRIIFJGQyAyMDQ1LiBMSU5FIEZFRURTIEFSRSBBRERFRCBUTyBFVkVSWSA2MCBFTkNPREVEIENIQVJBQ1RPUlMuIElOIFJVQlkgMS44IFdFIE5FRUQgVE8gSlVTVCBDQUxMIEVOQ09ERTY0IEFORCBSRU1PVkUgVEhFIExJTkUgRkVFRFMsIElOIFJVQlkgMS45IFdFIENBTEwgU1RSSUNfRU5DT0RFRDY0IE1FVEhPRCBJTlNURUFEIg==', 'verbose' => '1'}) 44 | end 45 | 46 | it 'should provide thorough information in case mixpanel fails' do 47 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:status => 401, :body => "nutcakes"}) 48 | expect { subject.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json) }.to raise_exception('Could not write to Mixpanel, server responded with 401 returning: \'nutcakes\'') 49 | end 50 | 51 | it 'should still respond to send' do 52 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 53 | subject.send(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json) 54 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 55 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' }) 56 | end 57 | 58 | it 'should raise server error if response body is empty' do 59 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => ''}) 60 | expect { subject.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json) }.to raise_exception(Mixpanel::ServerError, /Could not interpret Mixpanel server response: ''/) 61 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 62 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' }) 63 | end 64 | 65 | it 'should raise server error when verbose is disabled', :skip => true do 66 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '0'}) 67 | expect { subject.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json) }.to raise_exception(Mixpanel::ServerError, /Could not interpret Mixpanel server response: '0'/) 68 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 69 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' }) 70 | end 71 | end 72 | 73 | context 'raw consumer' do 74 | it_behaves_like 'consumer' 75 | end 76 | 77 | context 'custom request consumer' do 78 | subject do 79 | ret = Mixpanel::Consumer.new 80 | class << ret 81 | attr_reader :called 82 | def request(*args) 83 | @called = true 84 | super(*args) 85 | end 86 | end 87 | 88 | ret 89 | end 90 | 91 | after(:each) do 92 | expect(subject.called).to be_truthy 93 | end 94 | 95 | it_behaves_like 'consumer' 96 | end 97 | 98 | end 99 | 100 | describe Mixpanel::BufferedConsumer do 101 | let(:max_length) { 10 } 102 | before { WebMock.reset! } 103 | 104 | context 'Default BufferedConsumer' do 105 | subject { Mixpanel::BufferedConsumer.new(nil, nil, nil, max_length) } 106 | 107 | it 'should not send a request for a single message until flush is called' do 108 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 109 | subject.send!(:event, {'data' => 'TEST EVENT 1'}.to_json) 110 | expect(WebMock).to have_not_requested(:post, 'https://api.mixpanel.com/track') 111 | 112 | subject.flush() 113 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 114 | with(:body => {'data' => 'WyJURVNUIEVWRU5UIDEiXQ==', 'verbose' => '1' }) 115 | end 116 | 117 | it 'should still respond to send' do 118 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 119 | subject.send(:event, {'data' => 'TEST EVENT 1'}.to_json) 120 | expect(WebMock).to have_not_requested(:post, 'https://api.mixpanel.com/track') 121 | end 122 | 123 | it 'should send one message when max_length events are tracked' do 124 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 125 | 126 | max_length.times do |i| 127 | subject.send!(:event, {'data' => "x #{i}"}.to_json) 128 | end 129 | 130 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 131 | with(:body => {'data' => 'WyJ4IDAiLCJ4IDEiLCJ4IDIiLCJ4IDMiLCJ4IDQiLCJ4IDUiLCJ4IDYiLCJ4IDciLCJ4IDgiLCJ4IDkiXQ==', 'verbose' => '1' }) 132 | end 133 | 134 | it 'should send one message per api key on import' do 135 | stub_request(:any, 'https://api.mixpanel.com/import').to_return({:body => '{"status": 1, "error": null}'}) 136 | subject.send!(:import, {'data' => 'TEST EVENT 1', 'api_key' => 'KEY 1'}.to_json) 137 | subject.send!(:import, {'data' => 'TEST EVENT 1', 'api_key' => 'KEY 2'}.to_json) 138 | subject.send!(:import, {'data' => 'TEST EVENT 2', 'api_key' => 'KEY 1'}.to_json) 139 | subject.send!(:import, {'data' => 'TEST EVENT 2', 'api_key' => 'KEY 2'}.to_json) 140 | subject.flush 141 | 142 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). 143 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgMSI=', 'api_key' => 'KEY 1', 'verbose' => '1' }) 144 | 145 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). 146 | with(:body => {'data' => 'IlRFU1QgRVZFTlQgMSI=', 'api_key' => 'KEY 2', 'verbose' => '1' }) 147 | end 148 | end 149 | 150 | context 'BufferedConsumer with block' do 151 | let(:messages_seen) { [] } 152 | subject do 153 | Mixpanel::BufferedConsumer.new(nil, nil, nil, 3) do |type, message| 154 | messages_seen << [type, message] 155 | end 156 | end 157 | 158 | it 'should call block instead of making default requests on flush' do 159 | 3.times do |i| 160 | subject.send!(:event, {'data' => "x #{i}"}.to_json) 161 | end 162 | 163 | expect(messages_seen).to match_array( 164 | [[:event, "{\"data\":[\"x 0\",\"x 1\",\"x 2\"]}"]] 165 | ) 166 | end 167 | 168 | end 169 | 170 | context 'with failing requests' do 171 | let(:sent_messages) { [] } 172 | let(:submission_queue) { [] } 173 | subject do 174 | Mixpanel::BufferedConsumer.new(nil, nil, nil, 2) do |type, message| 175 | raise Mixpanel::ServerError if submission_queue.shift == :fail 176 | sent_messages << [type, message] 177 | end 178 | end 179 | 180 | it 'clears any slices that complete on flush' do 181 | # construct a consumer that is backed up and has a multi-slice buffer 182 | 3.times { submission_queue << :fail } 183 | 4.times do |i| 184 | begin 185 | subject.send!(:event, {'data' => i}.to_json) 186 | rescue Mixpanel::ServerError 187 | end 188 | end 189 | expect(sent_messages).to match_array([]) 190 | 191 | submission_queue << :pass 192 | submission_queue << :fail 193 | 194 | expect { subject.flush }.to raise_error Mixpanel::ServerError 195 | expect(sent_messages).to match_array([ 196 | [:event, '{"data":[0,1]}'] 197 | ]) 198 | 199 | submission_queue << :pass 200 | subject.flush 201 | expect(sent_messages).to match_array([ 202 | [:event, '{"data":[0,1]}'], 203 | [:event, '{"data":[2,3]}'], 204 | ]) 205 | end 206 | end 207 | 208 | end 209 | -------------------------------------------------------------------------------- /spec/mixpanel-ruby/error_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'mixpanel-ruby/error.rb' 4 | require 'mixpanel-ruby/events.rb' 5 | 6 | class TestErrorHandler < Mixpanel::ErrorHandler 7 | def initialize(log) 8 | @log = log 9 | end 10 | 11 | def handle(error) 12 | @log << error.to_s 13 | end 14 | end 15 | 16 | describe Mixpanel::ErrorHandler do 17 | it "should respond to #handle`" do 18 | error_handler = Mixpanel::ErrorHandler.new 19 | expect(error_handler.respond_to?(:handle)).to be true 20 | end 21 | 22 | context 'without a customer error_handler' do 23 | 24 | before(:each) do 25 | @tracker = Mixpanel::Tracker.new('TEST TOKEN') do |type, message| 26 | raise Mixpanel::MixpanelError 27 | end 28 | end 29 | 30 | it "should silence errors in track calls" do 31 | expect { 32 | expect(@tracker.track('TEST ID', 'Test Event')).to be false 33 | }.to_not raise_error 34 | end 35 | 36 | it "should handle errors in import calls" do 37 | expect { 38 | expect(@tracker.import('TEST API KEY', 'TEST DISTINCT_ID', 'Test Event')).to be false 39 | }.to_not raise_error 40 | end 41 | 42 | it "should handle errors in people calls" do 43 | expect { 44 | expect(@tracker.people.set('TEST ID', {})).to be false 45 | }.to_not raise_error 46 | end 47 | 48 | end 49 | 50 | context 'with a custom error_handler' do 51 | 52 | before(:each) do 53 | @log = [] 54 | @error_handler = TestErrorHandler.new(@log) 55 | @tracker = Mixpanel::Tracker.new('TEST TOKEN', @error_handler) do |type, message| 56 | raise Mixpanel::MixpanelError 57 | end 58 | end 59 | 60 | it "should handle errors in track calls" do 61 | @tracker.track('TEST ID', 'Test Event', {}) 62 | expect(@log).to eq(['Mixpanel::MixpanelError']) 63 | end 64 | 65 | it "should handle errors in import calls" do 66 | @tracker.import('TEST API KEY', 'TEST DISTINCT_ID', 'Test Event') 67 | expect(@log).to eq(['Mixpanel::MixpanelError']) 68 | end 69 | 70 | it "should handle errors in people calls" do 71 | @tracker.people.set('TEST ID', {}) 72 | expect(@log).to eq(['Mixpanel::MixpanelError']) 73 | end 74 | 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/mixpanel-ruby/events_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'time' 3 | 4 | require 'mixpanel-ruby/events.rb' 5 | require 'mixpanel-ruby/version.rb' 6 | 7 | describe Mixpanel::Events do 8 | before(:each) do 9 | @time_now = Time.parse('Jun 6 1972, 16:23:04') 10 | allow(Time).to receive(:now).and_return(@time_now) 11 | 12 | @log = [] 13 | @events = Mixpanel::Events.new('TEST TOKEN') do |type, message| 14 | @log << [type, JSON.load(message)] 15 | end 16 | end 17 | 18 | it 'should send a well formed track/ message' do 19 | @events.track('TEST ID', 'Test Event', { 20 | 'Circumstances' => 'During a test' 21 | }) 22 | expect(@log).to eq([[:event, 'data' => { 23 | 'event' => 'Test Event', 24 | 'properties' => { 25 | 'Circumstances' => 'During a test', 26 | 'distinct_id' => 'TEST ID', 27 | 'mp_lib' => 'ruby', 28 | '$lib_version' => Mixpanel::VERSION, 29 | 'token' => 'TEST TOKEN', 30 | 'time' => @time_now.to_i 31 | } 32 | }]]) 33 | end 34 | 35 | it 'should send a well formed import/ message' do 36 | @events.import('API_KEY', 'TEST ID', 'Test Event', { 37 | 'Circumstances' => 'During a test' 38 | }) 39 | expect(@log).to eq([[:import, { 40 | 'api_key' => 'API_KEY', 41 | 'data' => { 42 | 'event' => 'Test Event', 43 | 'properties' => { 44 | 'Circumstances' => 'During a test', 45 | 'distinct_id' => 'TEST ID', 46 | 'mp_lib' => 'ruby', 47 | '$lib_version' => Mixpanel::VERSION, 48 | 'token' => 'TEST TOKEN', 49 | 'time' => @time_now.to_i 50 | } 51 | } 52 | } ]]) 53 | end 54 | 55 | it 'should allow users to pass timestamp for import' do 56 | older_time = Time.parse('Jun 6 1971, 16:23:04') 57 | @events.import('API_KEY', 'TEST ID', 'Test Event', { 58 | 'Circumstances' => 'During a test', 59 | 'time' => older_time.to_i, 60 | }) 61 | expect(@log).to eq([[:import, { 62 | 'api_key' => 'API_KEY', 63 | 'data' => { 64 | 'event' => 'Test Event', 65 | 'properties' => { 66 | 'Circumstances' => 'During a test', 67 | 'distinct_id' => 'TEST ID', 68 | 'mp_lib' => 'ruby', 69 | '$lib_version' => Mixpanel::VERSION, 70 | 'token' => 'TEST TOKEN', 71 | 'time' => older_time.to_i, 72 | } 73 | } 74 | } ]]) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/mixpanel-ruby/groups_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'active_support/time' 3 | 4 | require 'mixpanel-ruby/groups' 5 | 6 | describe Mixpanel::Groups do 7 | before(:each) do 8 | @time_now = Time.parse('Jun 6 1972, 16:23:04') 9 | allow(Time).to receive(:now).and_return(@time_now) 10 | 11 | @log = [] 12 | @groups = Mixpanel::Groups.new('TEST TOKEN') do |type, message| 13 | @log << [type, JSON.load(message)] 14 | end 15 | end 16 | 17 | it 'should send a well formed groups/set message' do 18 | @groups.set("TEST GROUP KEY", "TEST GROUP ID", { 19 | '$groupname' => 'Mixpanel', 20 | '$grouprevenue' => 200 21 | }) 22 | expect(@log).to eq([[:group_update, 'data' => { 23 | '$token' => 'TEST TOKEN', 24 | '$group_key' => 'TEST GROUP KEY', 25 | '$group_id' => 'TEST GROUP ID', 26 | '$time' => @time_now.to_i * 1000, 27 | '$set' => { 28 | '$groupname' => 'Mixpanel', 29 | '$grouprevenue' => 200 30 | } 31 | }]]) 32 | end 33 | 34 | it 'should properly cast dates' do 35 | @groups.set("TEST GROUP KEY", "TEST GROUP ID", { 36 | 'created_at' => DateTime.new(2013, 1, 2, 3, 4, 5) 37 | }) 38 | expect(@log).to eq([[:group_update, 'data' => { 39 | '$token' => 'TEST TOKEN', 40 | '$group_key' => 'TEST GROUP KEY', 41 | '$group_id' => 'TEST GROUP ID', 42 | '$time' => @time_now.to_i * 1000, 43 | '$set' => { 44 | 'created_at' => '2013-01-02T03:04:05' 45 | } 46 | }]]) 47 | end 48 | 49 | it 'should convert offset datetimes to UTC' do 50 | @groups.set("TEST GROUP KEY", "TEST GROUP ID", { 51 | 'created_at' => DateTime.new(2013, 1, 1, 18, 4, 5, '-8') 52 | }) 53 | expect(@log).to eq([[:group_update, 'data' => { 54 | '$token' => 'TEST TOKEN', 55 | '$group_key' => 'TEST GROUP KEY', 56 | '$group_id' => 'TEST GROUP ID', 57 | '$time' => @time_now.to_i * 1000, 58 | '$set' => { 59 | 'created_at' => '2013-01-02T02:04:05' 60 | } 61 | }]]) 62 | end 63 | 64 | it 'should convert offset ActiveSupport::TimeWithZone objects to UTC' do 65 | Time.zone = 'Pacific Time (US & Canada)' 66 | @groups.set("TEST GROUP KEY", "TEST GROUP ID", { 67 | 'created_at' => Time.zone.local(2013, 1, 1, 18, 4, 5) 68 | }) 69 | expect(@log).to eq([[:group_update, 'data' => { 70 | '$token' => 'TEST TOKEN', 71 | '$group_key' => 'TEST GROUP KEY', 72 | '$group_id' => 'TEST GROUP ID', 73 | '$time' => @time_now.to_i * 1000, 74 | '$set' => { 75 | 'created_at' => '2013-01-02T02:04:05' 76 | } 77 | }]]) 78 | end 79 | 80 | it 'should send a well formed groups/set_once message' do 81 | @groups.set_once("TEST GROUP KEY", "TEST GROUP ID", { 82 | '$groupname' => 'Mixpanel', 83 | '$grouprevenue' => 200 84 | }) 85 | expect(@log).to eq([[:group_update, 'data' => { 86 | '$token' => 'TEST TOKEN', 87 | '$group_key' => 'TEST GROUP KEY', 88 | '$group_id' => 'TEST GROUP ID', 89 | '$time' => @time_now.to_i * 1000, 90 | '$set_once' => { 91 | '$groupname' => 'Mixpanel', 92 | '$grouprevenue' => 200 93 | } 94 | }]]) 95 | end 96 | 97 | it 'should send a well formed groups/remove message' do 98 | @groups.remove("TEST GROUP KEY", "TEST GROUP ID", { 99 | 'Albums' => 'Diamond Dogs' 100 | }) 101 | expect(@log).to eq([[:group_update, 'data' => { 102 | '$token' => 'TEST TOKEN', 103 | '$group_key' => 'TEST GROUP KEY', 104 | '$group_id' => 'TEST GROUP ID', 105 | '$time' => @time_now.to_i * 1000, 106 | '$remove' => { 107 | 'Albums' => 'Diamond Dogs' 108 | } 109 | }]]) 110 | end 111 | 112 | it 'should send a well formed groups/union message' do 113 | @groups.union("TEST GROUP KEY", "TEST GROUP ID", { 114 | 'Albums' => ['Diamond Dogs'] 115 | }) 116 | expect(@log).to eq([[:group_update, 'data' => { 117 | '$token' => 'TEST TOKEN', 118 | '$group_key' => 'TEST GROUP KEY', 119 | '$group_id' => 'TEST GROUP ID', 120 | '$time' => @time_now.to_i * 1000, 121 | '$union' => { 122 | 'Albums' => ['Diamond Dogs'] 123 | } 124 | }]]) 125 | end 126 | 127 | it 'should send a well formed unset message' do 128 | @groups.unset("TEST GROUP KEY", "TEST GROUP ID", 'Albums') 129 | expect(@log).to eq([[:group_update, 'data' => { 130 | '$token' => 'TEST TOKEN', 131 | '$group_key' => 'TEST GROUP KEY', 132 | '$group_id' => 'TEST GROUP ID', 133 | '$time' => @time_now.to_i * 1000, 134 | '$unset' => ['Albums'] 135 | }]]) 136 | end 137 | 138 | it 'should send a well formed unset message with multiple properties' do 139 | @groups.unset("TEST GROUP KEY", "TEST GROUP ID", ['Albums', 'Vinyls']) 140 | expect(@log).to eq([[:group_update, 'data' => { 141 | '$token' => 'TEST TOKEN', 142 | '$group_key' => 'TEST GROUP KEY', 143 | '$group_id' => 'TEST GROUP ID', 144 | '$time' => @time_now.to_i * 1000, 145 | '$unset' => ['Albums', 'Vinyls'] 146 | }]]) 147 | end 148 | 149 | it 'should send a well formed groups/delete message' do 150 | @groups.delete_group("TEST GROUP KEY", "TEST GROUP ID") 151 | expect(@log).to eq([[:group_update, 'data' => { 152 | '$token' => 'TEST TOKEN', 153 | '$group_key' => 'TEST GROUP KEY', 154 | '$group_id' => 'TEST GROUP ID', 155 | '$time' => @time_now.to_i * 1000, 156 | '$delete' => '' 157 | }]]) 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /spec/mixpanel-ruby/people_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'active_support/time' 3 | 4 | require 'mixpanel-ruby/people' 5 | 6 | describe Mixpanel::People do 7 | before(:each) do 8 | @time_now = Time.parse('Jun 6 1972, 16:23:04') 9 | allow(Time).to receive(:now).and_return(@time_now) 10 | 11 | @log = [] 12 | @people = Mixpanel::People.new('TEST TOKEN') do |type, message| 13 | @log << [type, JSON.load(message)] 14 | end 15 | end 16 | 17 | it 'should send a well formed engage/set message' do 18 | @people.set("TEST ID", { 19 | '$firstname' => 'David', 20 | '$lastname' => 'Bowie', 21 | }) 22 | expect(@log).to eq([[:profile_update, 'data' => { 23 | '$token' => 'TEST TOKEN', 24 | '$distinct_id' => 'TEST ID', 25 | '$time' => @time_now.to_i * 1000, 26 | '$set' => { 27 | '$firstname' => 'David', 28 | '$lastname' => 'Bowie' 29 | } 30 | }]]) 31 | end 32 | 33 | it 'should properly cast dates' do 34 | @people.set("TEST ID", { 35 | 'created_at' => DateTime.new(2013, 1, 2, 3, 4, 5) 36 | }) 37 | expect(@log).to eq([[:profile_update, 'data' => { 38 | '$token' => 'TEST TOKEN', 39 | '$distinct_id' => 'TEST ID', 40 | '$time' => @time_now.to_i * 1000, 41 | '$set' => { 42 | 'created_at' => '2013-01-02T03:04:05' 43 | } 44 | }]]) 45 | end 46 | 47 | it 'should convert offset datetimes to UTC' do 48 | @people.set("TEST ID", { 49 | 'created_at' => DateTime.new(2013, 1, 1, 18, 4, 5, '-8') 50 | }) 51 | expect(@log).to eq([[:profile_update, 'data' => { 52 | '$token' => 'TEST TOKEN', 53 | '$distinct_id' => 'TEST ID', 54 | '$time' => @time_now.to_i * 1000, 55 | '$set' => { 56 | 'created_at' => '2013-01-02T02:04:05' 57 | } 58 | }]]) 59 | end 60 | 61 | it 'should convert offset ActiveSupport::TimeWithZone objects to UTC' do 62 | Time.zone = 'Pacific Time (US & Canada)' 63 | @people.set("TEST ID", { 64 | 'created_at' => Time.zone.local(2013, 1, 1, 18, 4, 5) 65 | }) 66 | expect(@log).to eq([[:profile_update, 'data' => { 67 | '$token' => 'TEST TOKEN', 68 | '$distinct_id' => 'TEST ID', 69 | '$time' => @time_now.to_i * 1000, 70 | '$set' => { 71 | 'created_at' => '2013-01-02T02:04:05' 72 | } 73 | }]]) 74 | end 75 | 76 | it 'should send a well formed engage/set_once message' do 77 | @people.set_once("TEST ID", { 78 | '$firstname' => 'David', 79 | '$lastname' => 'Bowie', 80 | }) 81 | expect(@log).to eq([[:profile_update, 'data' => { 82 | '$token' => 'TEST TOKEN', 83 | '$distinct_id' => 'TEST ID', 84 | '$time' => @time_now.to_i * 1000, 85 | '$set_once' => { 86 | '$firstname' => 'David', 87 | '$lastname' => 'Bowie' 88 | } 89 | }]]) 90 | end 91 | 92 | it 'should send a well formed engage/add message' do 93 | @people.increment("TEST ID", {'Albums Released' => 10}) 94 | expect(@log).to eq([[:profile_update, 'data' => { 95 | '$token' => 'TEST TOKEN', 96 | '$distinct_id' => 'TEST ID', 97 | '$time' => @time_now.to_i * 1000, 98 | '$add' => { 99 | 'Albums Released' => 10 100 | } 101 | }]]) 102 | end 103 | 104 | it 'should send an engage/add message with a value of 1' do 105 | @people.plus_one("TEST ID", 'Albums Released') 106 | expect(@log).to eq([[:profile_update, 'data' => { 107 | '$token' => 'TEST TOKEN', 108 | '$distinct_id' => 'TEST ID', 109 | '$time' => @time_now.to_i * 1000, 110 | '$add' => { 111 | 'Albums Released' => 1 112 | } 113 | }]]) 114 | end 115 | 116 | it 'should send a well formed engage/append message' do 117 | @people.append("TEST ID", {'Albums' => 'Diamond Dogs'}) 118 | expect(@log).to eq([[:profile_update, 'data' => { 119 | '$token' => 'TEST TOKEN', 120 | '$distinct_id' => 'TEST ID', 121 | '$time' => @time_now.to_i * 1000, 122 | '$append' => { 123 | 'Albums' => 'Diamond Dogs' 124 | } 125 | }]]) 126 | end 127 | 128 | it 'should send a well formed engage/union message' do 129 | @people.union("TEST ID", {'Albums' => ['Diamond Dogs']}) 130 | expect(@log).to eq([[:profile_update, 'data' => { 131 | '$token' => 'TEST TOKEN', 132 | '$distinct_id' => 'TEST ID', 133 | '$time' => @time_now.to_i * 1000, 134 | '$union' => { 135 | 'Albums' => ['Diamond Dogs'] 136 | } 137 | }]]) 138 | end 139 | 140 | it 'should send a well formed unset message' do 141 | @people.unset('TEST ID', 'Albums') 142 | expect(@log).to eq([[:profile_update, 'data' => { 143 | '$token' => 'TEST TOKEN', 144 | '$distinct_id' => 'TEST ID', 145 | '$time' => @time_now.to_i * 1000, 146 | '$unset' => ['Albums'] 147 | }]]) 148 | end 149 | 150 | it 'should send a well formed unset message with multiple properties' do 151 | @people.unset('TEST ID', ['Albums', 'Vinyls']) 152 | expect(@log).to eq([[:profile_update, 'data' => { 153 | '$token' => 'TEST TOKEN', 154 | '$distinct_id' => 'TEST ID', 155 | '$time' => @time_now.to_i * 1000, 156 | '$unset' => ['Albums', 'Vinyls'] 157 | }]]) 158 | end 159 | 160 | it 'should send an engage/append with the right $transaction stuff' do 161 | @people.track_charge("TEST ID", 25.42, { 162 | '$time' => DateTime.new(1999,12,24,14, 02, 53), 163 | 'SKU' => '1234567' 164 | }) 165 | expect(@log).to eq([[:profile_update, 'data' => { 166 | '$token' => 'TEST TOKEN', 167 | '$distinct_id' => 'TEST ID', 168 | '$time' => @time_now.to_i * 1000, 169 | '$append' => { 170 | '$transactions' => { 171 | '$time' => '1999-12-24T14:02:53', 172 | 'SKU' => '1234567', 173 | '$amount' => 25.42 174 | } 175 | } 176 | }]]) 177 | end 178 | 179 | it 'should send a well formed engage/unset message for $transaction' do 180 | @people.clear_charges("TEST ID") 181 | expect(@log).to eq([[:profile_update, 'data' => { 182 | '$token' => 'TEST TOKEN', 183 | '$distinct_id' => 'TEST ID', 184 | '$time' => @time_now.to_i * 1000, 185 | '$unset' => ['$transactions'] 186 | }]]) 187 | end 188 | 189 | it 'should send a well formed engage/delete message' do 190 | @people.delete_user("TEST ID") 191 | expect(@log).to eq([[:profile_update, 'data' => { 192 | '$token' => 'TEST TOKEN', 193 | '$distinct_id' => 'TEST ID', 194 | '$time' => @time_now.to_i * 1000, 195 | '$delete' => '' 196 | }]]) 197 | end 198 | 199 | it 'should send a well formed engage/delete message with blank optional_params' do 200 | @people.delete_user("TEST ID", {}) 201 | expect(@log).to eq([[:profile_update, 'data' => { 202 | '$token' => 'TEST TOKEN', 203 | '$distinct_id' => 'TEST ID', 204 | '$time' => @time_now.to_i * 1000, 205 | '$delete' => '' 206 | }]]) 207 | end 208 | 209 | it 'should send a well formed engage/delete message with ignore_alias true' do 210 | @people.delete_user("TEST ID", {"$ignore_alias"=>true}) 211 | expect(@log).to eq([[:profile_update, 'data' => { 212 | '$token' => 'TEST TOKEN', 213 | '$distinct_id' => 'TEST ID', 214 | '$time' => @time_now.to_i * 1000, 215 | '$delete' => '', 216 | "$ignore_alias"=>true 217 | }]]) 218 | end 219 | 220 | end 221 | -------------------------------------------------------------------------------- /spec/mixpanel-ruby/tracker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'cgi' 3 | require 'json' 4 | require 'mixpanel-ruby' 5 | require 'uri' 6 | 7 | describe Mixpanel::Tracker do 8 | before(:each) do 9 | @time_now = Time.parse('Jun 6 1972, 16:23:04') 10 | allow(Time).to receive(:now).and_return(@time_now) 11 | end 12 | 13 | it 'should send an alias message to mixpanel no matter what the consumer is' do 14 | WebMock.reset! 15 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 16 | mixpanel = Mixpanel::Tracker.new('TEST TOKEN') {|*args| } 17 | mixpanel.alias('TEST ALIAS', 'TEST ID') 18 | 19 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 20 | with(:body => {:data => 'eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImRpc3RpbmN0X2lkIjoiVEVTVCBJRCIsImFsaWFzIjoiVEVTVCBBTElBUyIsInRva2VuIjoiVEVTVCBUT0tFTiJ9fQ==', 'verbose' => '1'}) 21 | end 22 | 23 | it 'should generate pixel tracking urls correctly' do 24 | mixpanel = Mixpanel::Tracker.new('TEST TOKEN') 25 | event = 'TEST EVENT' 26 | properties = {'Circumstances' => 'During test'} 27 | default_properties = { 28 | 'distinct_id' => 'TEST_ID', 29 | 'mp_lib' => 'ruby', 30 | '$lib_version' => Mixpanel::VERSION, 31 | 'token' => 'TEST TOKEN', 32 | 'time' => @time_now.to_i 33 | } 34 | expected_data = {'event' => event, 'properties' => properties.merge(default_properties)} 35 | 36 | url_string = mixpanel.generate_tracking_url('TEST_ID', event, properties) 37 | 38 | url = URI(url_string) 39 | expect(url.scheme).to eq('https') 40 | expect(url.host).to eq('api.mixpanel.com') 41 | expect(url.path).to eq('/track/') 42 | 43 | parsed_query = CGI.parse(url.query) 44 | expect(parsed_query['ip'][0]).to eq('1') 45 | expect(parsed_query['img'][0]).to eq('1') 46 | 47 | data = JSON.parse(Base64.urlsafe_decode64(parsed_query['data'][0])) 48 | expect(data).to eq(expected_data) 49 | end 50 | 51 | it 'should send a request to the track api with the default consumer' do 52 | WebMock.reset! 53 | stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) 54 | stub_request(:any, 'https://api.mixpanel.com/engage').to_return({:body => '{"status": 1, "error": null}'}) 55 | mixpanel = Mixpanel::Tracker.new('TEST TOKEN') 56 | 57 | mixpanel.track('TEST ID', 'TEST EVENT', {'Circumstances' => 'During test'}) 58 | 59 | body = nil 60 | expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/track'). 61 | with { |req| body = req.body } 62 | 63 | message_urlencoded = body[/^data=(.*?)(?:&|$)/, 1] 64 | message_json = Base64.strict_decode64(URI.unescape(message_urlencoded)) 65 | message = JSON.load(message_json) 66 | expect(message).to eq({ 67 | 'event' => 'TEST EVENT', 68 | 'properties' => { 69 | 'Circumstances' => 'During test', 70 | 'distinct_id' => 'TEST ID', 71 | 'mp_lib' => 'ruby', 72 | '$lib_version' => Mixpanel::VERSION, 73 | 'token' => 'TEST TOKEN', 74 | 'time' => @time_now.to_i 75 | } 76 | }) 77 | end 78 | 79 | it 'should call a consumer block if one is given' do 80 | messages = [] 81 | mixpanel = Mixpanel::Tracker.new('TEST TOKEN') do |type, message| 82 | messages << [type, JSON.load(message)] 83 | end 84 | mixpanel.track('ID', 'Event') 85 | mixpanel.import('API_KEY', 'ID', 'Import') 86 | mixpanel.people.set('ID', {'k' => 'v'}) 87 | mixpanel.people.append('ID', {'k' => 'v'}) 88 | 89 | expect = [ 90 | [ :event, 'data' => 91 | { 'event' => 'Event', 92 | 'properties' => { 93 | 'distinct_id' => 'ID', 94 | 'mp_lib' => 'ruby', 95 | '$lib_version' => Mixpanel::VERSION, 96 | 'token' => 'TEST TOKEN', 97 | 'time' => @time_now.to_i 98 | } 99 | } 100 | ], 101 | [ :import, { 102 | 'data' => { 103 | 'event' => 'Import', 104 | 'properties' => { 105 | 'distinct_id' => 'ID', 106 | 'mp_lib' => 'ruby', 107 | '$lib_version' => Mixpanel::VERSION, 108 | 'token' => 'TEST TOKEN', 109 | 'time' => @time_now.to_i 110 | } 111 | }, 112 | 'api_key' => 'API_KEY', 113 | } 114 | ], 115 | [ :profile_update, 'data' => 116 | { '$token' => 'TEST TOKEN', 117 | '$distinct_id' => 'ID', 118 | '$time' => @time_now.to_i * 1000, 119 | '$set' => {'k' => 'v'} 120 | } 121 | ], 122 | [ :profile_update, 'data' => 123 | { '$token' => 'TEST TOKEN', 124 | '$distinct_id' => 'ID', 125 | '$time' => @time_now.to_i * 1000, 126 | '$append' => {'k' => 'v'} 127 | } 128 | ] 129 | ] 130 | expect.zip(messages).each do |expect, found| 131 | expect(expect).to eq(found) 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'webmock/rspec' 3 | 4 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 5 | RSpec.configure do |config| 6 | config.run_all_when_everything_filtered = true 7 | config.filter_run :focus 8 | config.raise_errors_for_deprecations! 9 | 10 | # Run specs in random order to surface order dependencies. If you find an 11 | # order dependency and want to debug it, you can fix the order by providing 12 | # the seed, which is printed after each run. 13 | # --seed 1234 14 | config.order = 'random' 15 | end 16 | --------------------------------------------------------------------------------