diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7373194 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "bundler" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 30 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 30 diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index c981835..f91f822 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -22,7 +22,7 @@ jobs: - '3.4' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0 with: @@ -31,7 +31,7 @@ jobs: - name: Run tests run: bundle exec rake - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: token: ${{ secrets.CODECOV_TOKEN }} slug: mixpanel/mixpanel-ruby diff --git a/.gitignore b/.gitignore index b248008..eea7e07 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ Gemfile.lock html mixpanel-ruby*.gem .bundle +coverage/ +Readme.rdoc diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef36e54 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# mixpanel-ruby: The official Mixpanel Ruby library + +mixpanel-ruby is a library for tracking events and sending Mixpanel profile +updates to Mixpanel from your Ruby applications. + +## Installation + +``` +gem install mixpanel-ruby +``` + +## Getting Started + +```ruby +require 'mixpanel-ruby' + +tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + +# Track an event on behalf of user "User1" +tracker.track('User1', 'A Mixpanel Event') + +# Send an update to User1's profile +tracker.people.set('User1', { + '$first_name' => 'David', + '$last_name' => 'Bowie', + 'Best Album' => 'The Rise and Fall of Ziggy Stardust and the Spiders from Mars' +}) +``` + +The primary class you will use to track events is `Mixpanel::Tracker`. An instance of +`Mixpanel::Tracker` is enough to send events directly to Mixpanel, and get you integrated +right away. + +## Importing Events + +> **⚠️ Breaking change in v3.1.0:** The `api_key` string argument to `Tracker#import` has been replaced by a credentials Hash supporting Service Account or Project Token authentication. +> More details here: https://developer.mixpanel.com/reference/import-events#authentication and https://developer.mixpanel.com/reference/service-accounts + +Use `Mixpanel::Tracker#import` to import historical events. The import endpoint +requires authentication via HTTP Basic Auth using one of the following methods: + +### Service Account (recommended) + +```ruby +tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + +tracker.import( + { service_account_username: 'sa@serviceaccount.mixpanel.com', + service_account_password: 'sa-secret', + project_id: '12345' }, + 'user_id', 'Event Name', { 'time' => 1310111365 } +) +``` + +### Project Token + +The project token is sent as the HTTP Basic Auth username with an empty password. + +```ruby +tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + +tracker.import( + { project_token: YOUR_MIXPANEL_TOKEN }, + 'user_id', 'Event Name', { 'time' => 1310111365 } +) +``` + +## Additional Information + +For more information please visit: + +* [Our Ruby API Integration page](https://mixpanel.com/help/reference/ruby#introduction) +* [The usage demo](https://github.com/mixpanel/mixpanel-ruby/tree/master/demo) +* [The documentation](http://mixpanel.github.io/mixpanel-ruby/) + +The official Mixpanel gem is built with simplicity and broad applicability in +mind, but there are also third party Ruby libraries that can work with the library +to provide useful features in common situations, and support different development +points of view. + +In particular, for Rails apps, the following projects are currently actively maintained: + +* [MetaEvents](https://github.com/swiftype/meta_events) +* [Mengpaneel](https://github.com/DouweM/mengpaneel) + +## Changes + +### 3.1.0 +* Add service account and project token authentication for the `/import` endpoint + (breaking change: the first argument to `Tracker#import` has changed from an `api_key` string + to a credentials Hash — all other methods are unaffected) + +### 3.0.0 +* Implement feature flags provider + +### 2.3.1 +* Convert timestamps to milliseconds and update Ruby compatibility + +### 2.3.0 +* Clear submitted slices during `BufferedConsumer#flush` +* Groups analytics support +* Use millisecond precision for time properties + +### 2.2.2 +* Add Group Analytics support with `Mixpanel::Groups` + +### 2.2.1 +* Fix buffer clearing on partially successful writes in `BufferedConsumer` + +### 2.2.0 +* Add `Mixpanel::ErrorHandler` to simplify custom error handling +* Modify `Mixpanel::People#fix_property_dates` to handle `ActiveSupport::TimeWithZone` +* Increase open and ssl timeouts from 2s to 10s +* Fix doc inconsistency: always pass token on `Mixpanel::Tracker.new` + +### 2.1.0 +* Add `Mixpanel::Tracker#generate_tracking_url`, which generates [pixel tracking urls](https://mixpanel.com/docs/api-documentation/pixel-based-event-tracking) +* Rescue JSONErrors in the consumer and raise `Mixpanel::ServerError` in `Mixpanel::Consumer#send!` +* Make it clear how to import events with custom timestamp +* Update dependencies in gemspec + +### 2.0.1 +* Add deprecated version of `Mixpanel::BufferedConsumer#send` + +### 2.0.0 +* Raise Mixpanel server and connection errors in `Mixpanel::Consumer` +* All public methods in `Mixpanel::Event`, `Mixpanel::People`, and subsequently `Mixpanel::Tracker` + rescue Mixpanel errors and return false in the case of an error, return true otherwise +* Deprecate `Mixpanel::Consumer#send`, replace with `Mixpanel::Consumer#send!` +* Require Ruby version minimum of 2.0.0 + +### 1.4.0 +* Allow unset to unset multiple properties + +### 1.3.0 +* Added `Consumer#request` method, demo with Faraday integration + +### 1.2.0 +* All objects with a `strftime` method will be formatted as dates in people updates + +### 1.1.0 +* The default consumer now sends requests (and expects responses) in verbose, JSON mode, + which may improve error reporting + +### 1.0.2 +* Allow ip and optional_params arguments to be accepted by all `Mixpanel::People` methods + (except `#destroy_user`) + +### 1.0.1 +* Compatibility with earlier versions of Ruby + +### 1.0.0 +* `tracker#import` added +* Change to internal tracking message format. Messages written by earlier versions of the + library will not work with 1.0.0 consumer classes +* alias bugfixed +* Fixes to tests to allow for different timezones +* Support for optional/experimental people API properties in people calls diff --git a/Readme.rdoc b/Readme.rdoc deleted file mode 100644 index 43f5502..0000000 --- a/Readme.rdoc +++ /dev/null @@ -1,115 +0,0 @@ -= mixpanel-ruby: The official Mixpanel Ruby library - -mixpanel-ruby is a library for tracking events and sending \Mixpanel profile -updates to \Mixpanel from your ruby applications. - -== Installation - - gem install mixpanel-ruby - -== Getting Started - - require 'mixpanel-ruby' - - tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) - - # Track an event on behalf of user "User1" - tracker.track('User1', 'A Mixpanel Event') - - # Send an update to User1's profile - tracker.people.set('User1', { - '$first_name' => 'David', - '$last_name' => 'Bowie', - 'Best Album' => 'The Rise and Fall of Ziggy Stardust and the Spiders from Mars' - }) - -The primary class you will use to track events is Mixpanel::Tracker. An instance of -Mixpanel::Tracker is enough to send events directly to \Mixpanel, and get you integrated -right away. - -== Additional Information - -For more information please visit: - -* Our Ruby API Integration page[https://mixpanel.com/help/reference/ruby#introduction] -* The usage demo[https://github.com/mixpanel/mixpanel-ruby/tree/master/demo] -* The documentation[http://mixpanel.github.io/mixpanel-ruby/] - -The official Mixpanel gem is built with simplicity and broad applicability in -mind, but there are also third party Ruby libraries that can work with the library -to provide useful features in common situations, and support different development -points of view. - -In particular, for Rails apps, the following projects are currently actively maintained: - -* MetaEvents[https://github.com/swiftype/meta_events] -* Mengpaneel[https://github.com/DouweM/mengpaneel] - -== Changes - -== 2.3.1 -* Convert timestamps to milliseconds and update Ruby compatibility - -== 2.3.0 -* Clear submitted slices during BufferedConsumer#flush -* Groups analytics support -* use millisecond precision for time properties - -== 2.2.2 -* Add Group Analytics support with Mixpanel::Groups - -== 2.2.1 -* Fix buffer clearing on partially successful writes in BufferedConsumer. - -== 2.2.0 -* Add Mixpanel::ErrorHandler to simplify custom error handling. -* Modify Mixpanel::People#fix_property_dates to handle ActiveSupport::TimeWithZone. -* Increase open and ssl timeouts from 2s to 10s. -* Fix Doc inconsistancy: always pass token on Mixpanel::tracker.new. - -== 2.1.0 -* Add Mixpanel::Tracker#generate_tracking_url, which generates {pixel tracking urls}[https://mixpanel.com/docs/api-documentation/pixel-based-event-tracking]. -* Rescue JSONErrors in the consumer and raise Mixpanel::ServerError in Mixpanel::Consumer#send!. -* Make it clear how to import events with custom timestamp. -* Update dependancies in gemspec - -== 2.0.1 -* Add Deprecated version of Mixpanel::BufferedConsumer#send - -== 2.0.0 -* Raise mixpanel server and connection errors in Mixpanel::Consumer. -* All public methods in Mixpanel::Event, Mixpanel::People, and subsequently Mixpanel::Tracker - rescue Mixpanel errors and return false in the case of an error, return true otherwise -* Deprecate Mixpanel::Consumer#send, replace with Mixpanel::Consumer#send! -* Require ruby version minimum of 2.0.0 - -== 1.4.0 -* Allow unset to unset multiple properties - -== 1.3.0 -* Added Consumer#request method, demo with Faraday integration - -== 1.2.0 -* All objects with a "strftime" method will be formatted as dates in - people updates. - -== 1.1.0 -* The default consumer now sends requests (and expects responses) in - verbose, JSON mode, which may improve error reporting. - -=== 1.0.2 -* Allow ip and optional_params arguments to be accepted by all - Mixpanel::People methods (except #destroy_user) - -=== 1.0.1 -* Compatibility with earlier versions of ruby. Library development will continue - to target 1.9, so later versions may not be compatible with Ruby 1.8, but we - love patches! - -=== 1.0.0 -* tracker#import added -* Change to internal tracking message format. Messages written - by earlier versions of the library will not work with 1.0.0 consumer classes. -* alias bugfixed -* Fixes to tests to allow for different timezones -* Support for optional/experimental people api properties in people calls diff --git a/demo/import_project_token_example.rb b/demo/import_project_token_example.rb new file mode 100644 index 0000000..1b3a7c7 --- /dev/null +++ b/demo/import_project_token_example.rb @@ -0,0 +1,57 @@ +#!/usr/bin/env ruby +# Example 02: Import Historical Events — Project Token Auth +# +# Use tracker.import with a project token when you don't have a service account. +# The token is sent as the HTTP Basic Auth username with an empty password. +# +# Run: bundle exec ruby demo/import_project_token_example.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'mixpanel-ruby' + +# ── Configuration ───────────────────────────────────────────────────────────── +PROJECT_TOKEN = 'your_project_token' +# ────────────────────────────────────────────────────────────────────────────── + +tracker = Mixpanel::Tracker.new(PROJECT_TOKEN) + +credentials = { project_token: PROJECT_TOKEN } + +puts '--- Import a single historical event ---' +result = tracker.import(credentials, 'user_001', 'Subscription Started', { + 'plan' => 'Pro', + 'billing_period' => 'monthly', + 'time' => Time.parse('2024-03-10 12:00:00 UTC').to_i, +}) +puts "Success: #{result}" + +puts "\n--- Import with no explicit timestamp (defaults to now) ---" +result = tracker.import(credentials, 'user_002', 'Feature Used', { + 'feature_name' => 'dark_mode', +}) +puts "Success: #{result}" + +puts "\n--- Import multiple distinct users ---" +users = %w[user_100 user_101 user_102 user_103] +users.each_with_index do |user_id, idx| + timestamp = (Time.now - (idx * 86_400)).to_i # each event one day apart + result = tracker.import(credentials, user_id, 'Daily Active', { + 'day_offset' => idx, + 'time' => timestamp, + }) + puts " #{user_id}: #{result ? 'ok' : 'FAILED'}" +end + +puts "\n--- Import with rich properties ---" +result = tracker.import(credentials, 'user_050', 'Order Placed', { + 'order_id' => 'ORD-9876', + 'items' => 3, + 'total_usd' => 124.50, + 'coupon_used' => true, + 'channel' => 'email_campaign', + 'campaign_id' => 'summer_sale_2024', + 'time' => Time.parse('2024-07-04 18:30:00 UTC').to_i, +}) +puts "Success: #{result}" + +puts "\nDone." diff --git a/demo/import_service_account_example.rb b/demo/import_service_account_example.rb new file mode 100644 index 0000000..6e7ae23 --- /dev/null +++ b/demo/import_service_account_example.rb @@ -0,0 +1,66 @@ +#!/usr/bin/env ruby +# Example 01: Import Historical Events — Service Account Auth +# +# Use tracker.import with a service account when you need to backfill historical +# events. The SDK authenticates via HTTP Basic Auth and passes the project_id as +# a query parameter to the /import endpoint. +# +# Run: bundle exec ruby demo/import_service_account_example.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'mixpanel-ruby' + +# ── Configuration ───────────────────────────────────────────────────────────── +PROJECT_TOKEN = 'your_token_here' +SERVICE_ACCOUNT_USERNAME = 'sa-user' # e.g. sa-abc123@serviceaccount.mixpanel.com +SERVICE_ACCOUNT_PASSWORD = 'sa-password' +PROJECT_ID = 'your_p_id' # numeric project ID as a string +# ────────────────────────────────────────────────────────────────────────────── + +tracker = Mixpanel::Tracker.new(PROJECT_TOKEN) + +credentials = { + service_account_username: SERVICE_ACCOUNT_USERNAME, + service_account_password: SERVICE_ACCOUNT_PASSWORD, + project_id: PROJECT_ID, +} + +puts '--- Import a single historical event ---' +result = tracker.import(credentials, 'user_001', 'Purchase Completed', { + 'product_name' => 'Vintage Widget', + 'price' => 49.99, + 'time' => Time.parse('2024-01-15 10:30:00 UTC').to_i, +}) +puts "Success: #{result}" + +puts "\n--- Import with a very old timestamp ---" +result = tracker.import(credentials, 'user_002', 'Account Created', { + 'plan' => 'Free', + 'source' => 'organic', + 'time' => Time.parse('2020-06-01 08:00:00 UTC').to_i, +}) +puts "Success: #{result}" + +puts "\n--- Import with IP for geolocation ---" +result = tracker.import( + credentials, + 'user_003', + 'App Installed', + { 'platform' => 'iOS', 'time' => Time.parse('2023-03-20 14:45:00 UTC').to_i }, + '203.0.113.10' # ip +) +puts "Success: #{result}" + +puts "\n--- Batch import: multiple events in a loop ---" +events = [ + { user: 'user_010', event: 'Video Started', ts: '2024-02-01 09:00:00 UTC', props: { 'video_id' => 'v_001' } }, + { user: 'user_010', event: 'Video Paused', ts: '2024-02-01 09:04:32 UTC', props: { 'video_id' => 'v_001', 'position_sec' => 272 } }, + { user: 'user_010', event: 'Video Finished', ts: '2024-02-01 09:18:10 UTC', props: { 'video_id' => 'v_001' } }, +] + +events.each do |e| + result = tracker.import(credentials, e[:user], e[:event], e[:props].merge('time' => Time.parse(e[:ts]).to_i)) + puts " Import '#{e[:event]}': #{result ? 'ok' : 'FAILED'}" +end + +puts "\nDone." diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index 3078f66..35fa705 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -84,14 +84,30 @@ def send!(type, message) }[type] decoded_message = JSON.load(message) - api_key = decoded_message["api_key"] + credentials = decoded_message["credentials"] data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '') form_data = {"data" => data, "verbose" => 1} - form_data.merge!("api_key" => api_key) if api_key + + basic_auth = nil + if type == :import && credentials + case credentials["type"] + when "service_account" + basic_auth = [credentials["username"], credentials["password"]] + uri = URI(endpoint) + uri.query = URI.encode_www_form((URI.decode_www_form(uri.query || '') << ['project_id', credentials["project_id"]])) + endpoint = uri.to_s + when "project_token" + basic_auth = [credentials["token"], ""] + end + end begin - response_code, response_body = request(endpoint, form_data) + if basic_auth + response_code, response_body = request(endpoint, form_data, basic_auth: basic_auth) + else + response_code, response_body = request(endpoint, form_data) + end rescue => e raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".") end @@ -123,10 +139,11 @@ def send(type, message) # # as the result of the response. Response code should be nil if # the request never receives a response for some reason. - def request(endpoint, form_data) + def request(endpoint, form_data, basic_auth: nil) uri = URI(endpoint) request = Net::HTTP::Post.new(uri.request_uri) request.set_form_data(form_data) + request.basic_auth(*basic_auth) if basic_auth client = Net::HTTP.new(uri.host, uri.port) client.use_ssl = true diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index be653cd..944d592 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -87,19 +87,46 @@ def track(distinct_id, event, properties={}, ip=nil) # we pass the time of the method call as the time the event occured, if you # wish to override this pass a timestamp in the properties hash. # - # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + # The credentials argument must be a Hash specifying one of two + # authentication methods: # - # # Track that user "12345"'s credit card was declined - # tracker.import("API_KEY", "12345", "Credit Card Declined") + # Service account (recommended): # - # # Properties describe the circumstances of the event, - # # or aspects of the source or user associated with the event - # tracker.import("API_KEY", "12345", "Welcome Email Sent", { - # 'Email Template' => 'Pretty Pink Welcome', - # 'User Sign-up Cohort' => 'July 2013', - # 'time' => 1369353600, - # }) - def import(api_key, distinct_id, event, properties={}, ip=nil) + # tracker.import( + # { service_account_username: 'sa@serviceaccount.mixpanel.com', + # service_account_password: 'sa-secret', + # project_id: '12345' }, + # "12345", "Credit Card Declined", { 'time' => 1310111365 } + # ) + # + # Project token (token sent as Basic Auth username, empty password): + # + # tracker.import( + # { project_token: YOUR_MIXPANEL_TOKEN }, + # "12345", "Credit Card Declined", { 'time' => 1310111365 } + # ) + def import(credentials, distinct_id, event, properties={}, ip=nil) + raise ArgumentError, "credentials must be a Hash with :service_account_username/:service_account_password/:project_id or :project_token (got #{credentials.class})" unless credentials.is_a?(Hash) + credentials = credentials.transform_keys(&:to_sym) + credentials_data = if credentials[:service_account_username] + missing = [:service_account_username, :service_account_password, :project_id].select { |k| credentials[k].to_s.strip.empty? } + raise ArgumentError, "service account credentials missing required fields: #{missing.join(', ')}" unless missing.empty? + { + 'type' => 'service_account', + 'username' => credentials[:service_account_username], + 'password' => credentials[:service_account_password], + 'project_id' => credentials[:project_id].to_s, + } + elsif credentials[:project_token] + raise ArgumentError, ":project_token must not be blank" if credentials[:project_token].to_s.strip.empty? + { + 'type' => 'project_token', + 'token' => credentials[:project_token], + } + else + raise ArgumentError, "credentials must include :service_account_username/:service_account_password/:project_id or :project_token" + end + properties = { 'distinct_id' => distinct_id, 'token' => @token, @@ -115,8 +142,8 @@ def import(api_key, distinct_id, event, properties={}, ip=nil) } message = { - 'data' => data, - 'api_key' => api_key, + 'data' => data, + 'credentials' => credentials_data, } ret = true diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 667c672..a3c3e21 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -114,27 +114,34 @@ def track(distinct_id, event, properties={}, ip=nil) end # A call to #import is to import an event occurred in the past. #import - # takes a distinct_id representing the source of that event (for - # example, a user id), an event name describing the event, and a + # takes a credentials Hash, a distinct_id representing the source of that + # event (for example, a user id), an event name describing the event, and a # set of properties describing that event. Properties are provided # as a Hash with string keys and strings, numbers or booleans as # values. # - # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + # The credentials Hash must specify one of two authentication methods: # - # # Import event that user "12345"'s credit card was declined - # tracker.import("API_KEY", "12345", "Credit Card Declined", { - # 'time' => 1310111365 - # }) + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) # - # # Properties describe the circumstances of the event, - # # or aspects of the source or user associated with the event - # tracker.import("API_KEY", "12345", "Welcome Email Sent", { + # # Service account authentication (recommended) + # tracker.import( + # { service_account_username: 'sa@serviceaccount.mixpanel.com', + # service_account_password: 'sa-secret', + # project_id: '12345' }, + # "12345", "Credit Card Declined", { 'time' => 1310111365 } + # ) + # + # # Project token authentication + # tracker.import( + # { project_token: YOUR_MIXPANEL_TOKEN }, + # "12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1310111365 - # }) - def import(api_key, distinct_id, event, properties={}, ip=nil) + # } + # ) + def import(credentials, distinct_id, event, properties={}, ip=nil) # This is here strictly to allow rdoc to include the relevant # documentation super diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 941f256..9638ea5 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -29,11 +29,30 @@ with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' }) end - it 'should send a request to api.mixpanel.com/import on event imports' do + it 'should send a request to api.mixpanel.com/import using project token auth' do stub_request(:any, 'https://api.mixpanel.com/import').to_return({:body => '{"status": 1, "error": null}'}) - subject.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'api_key' => 'API_KEY','verbose' => '1' }.to_json) + subject.send!(:import, { + 'data' => 'TEST EVENT MESSAGE', + 'credentials' => { 'type' => 'project_token', 'token' => 'MY_TOKEN' } + }.to_json) expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). - with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'api_key' => 'API_KEY', 'verbose' => '1' }) + with( + headers: { 'Authorization' => 'Basic ' + Base64.strict_encode64('MY_TOKEN:') }, + body: { 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' } + ) + end + + it 'should send a request to api.mixpanel.com/import using service account auth' do + stub_request(:any, /api\.mixpanel\.com\/import/).to_return({:body => '{"status": 1, "error": null}'}) + subject.send!(:import, { + 'data' => 'TEST EVENT MESSAGE', + 'credentials' => { 'type' => 'service_account', 'username' => 'u', 'password' => 'p', 'project_id' => '99' } + }.to_json) + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=99'). + with( + headers: { 'Authorization' => 'Basic ' + Base64.strict_encode64('u:p') }, + body: { 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'verbose' => '1' } + ) end it 'should encode long messages without newlines' do @@ -79,9 +98,9 @@ ret = Mixpanel::Consumer.new class << ret attr_reader :called - def request(*args) + def request(*args, **kwargs) @called = true - super(*args) + super(*args, **kwargs) end end @@ -131,19 +150,22 @@ def request(*args) with(:body => {'data' => 'WyJ4IDAiLCJ4IDEiLCJ4IDIiLCJ4IDMiLCJ4IDQiLCJ4IDUiLCJ4IDYiLCJ4IDciLCJ4IDgiLCJ4IDkiXQ==', 'verbose' => '1' }) end - it 'should send one message per api key on import' do + it 'should send import messages immediately (not buffered)' do stub_request(:any, 'https://api.mixpanel.com/import').to_return({:body => '{"status": 1, "error": null}'}) - subject.send!(:import, {'data' => 'TEST EVENT 1', 'api_key' => 'KEY 1'}.to_json) - subject.send!(:import, {'data' => 'TEST EVENT 1', 'api_key' => 'KEY 2'}.to_json) - subject.send!(:import, {'data' => 'TEST EVENT 2', 'api_key' => 'KEY 1'}.to_json) - subject.send!(:import, {'data' => 'TEST EVENT 2', 'api_key' => 'KEY 2'}.to_json) - subject.flush + subject.send!(:import, {'data' => 'TEST EVENT 1', 'credentials' => {'type' => 'project_token', 'token' => 'TOKEN 1'}}.to_json) + subject.send!(:import, {'data' => 'TEST EVENT 2', 'credentials' => {'type' => 'project_token', 'token' => 'TOKEN 1'}}.to_json) expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). - with(:body => {'data' => 'IlRFU1QgRVZFTlQgMSI=', 'api_key' => 'KEY 1', 'verbose' => '1' }) + with( + headers: { 'Authorization' => 'Basic ' + Base64.strict_encode64('TOKEN 1:') }, + body: { 'data' => 'IlRFU1QgRVZFTlQgMSI=', 'verbose' => '1' } + ) expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). - with(:body => {'data' => 'IlRFU1QgRVZFTlQgMSI=', 'api_key' => 'KEY 2', 'verbose' => '1' }) + with( + headers: { 'Authorization' => 'Basic ' + Base64.strict_encode64('TOKEN 1:') }, + body: { 'data' => 'IlRFU1QgRVZFTlQgMiI=', 'verbose' => '1' } + ) end end diff --git a/spec/mixpanel-ruby/error_spec.rb b/spec/mixpanel-ruby/error_spec.rb index 2915917..77bfbf9 100644 --- a/spec/mixpanel-ruby/error_spec.rb +++ b/spec/mixpanel-ruby/error_spec.rb @@ -35,7 +35,7 @@ def handle(error) it "should handle errors in import calls" do expect { - expect(@tracker.import('TEST API KEY', 'TEST DISTINCT_ID', 'Test Event')).to be false + expect(@tracker.import({ project_token: 'TEST TOKEN' }, 'TEST DISTINCT_ID', 'Test Event')).to be false }.to_not raise_error end @@ -63,7 +63,7 @@ def handle(error) end it "should handle errors in import calls" do - @tracker.import('TEST API KEY', 'TEST DISTINCT_ID', 'Test Event') + @tracker.import({ project_token: 'TEST TOKEN' }, 'TEST DISTINCT_ID', 'Test Event') expect(@log).to eq(['Mixpanel::MixpanelError']) end diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index 57f6ade..64aa88f 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -32,12 +32,44 @@ }]]) end - it 'should send a well formed import/ message' do - @events.import('API_KEY', 'TEST ID', 'Test Event', { - 'Circumstances' => 'During a test' - }) + it 'should send a well formed import/ message with service account credentials' do + @events.import( + { service_account_username: 'sa@serviceaccount.mixpanel.com', + service_account_password: 'sa-secret', + project_id: '12345' }, + 'TEST ID', 'Test Event', { 'Circumstances' => 'During a test' } + ) + expect(@log).to eq([[:import, { + 'credentials' => { + 'type' => 'service_account', + 'username' => 'sa@serviceaccount.mixpanel.com', + 'password' => 'sa-secret', + 'project_id' => '12345', + }, + 'data' => { + 'event' => 'Test Event', + 'properties' => { + 'Circumstances' => 'During a test', + 'distinct_id' => 'TEST ID', + 'mp_lib' => 'ruby', + '$lib_version' => Mixpanel::VERSION, + 'token' => 'TEST TOKEN', + 'time' => @time_now.to_i * 1000 + } + } + }]]) + end + + it 'should send a well formed import/ message with project token credentials' do + @events.import( + { project_token: 'MY_PROJECT_TOKEN' }, + 'TEST ID', 'Test Event', { 'Circumstances' => 'During a test' } + ) expect(@log).to eq([[:import, { - 'api_key' => 'API_KEY', + 'credentials' => { + 'type' => 'project_token', + 'token' => 'MY_PROJECT_TOKEN', + }, 'data' => { 'event' => 'Test Event', 'properties' => { @@ -49,17 +81,21 @@ 'time' => @time_now.to_i * 1000 } } - } ]]) + }]]) end it 'should allow users to pass timestamp for import' do older_time = Time.parse('Jun 6 1971, 16:23:04') - @events.import('API_KEY', 'TEST ID', 'Test Event', { - 'Circumstances' => 'During a test', - 'time' => older_time.to_i, - }) + @events.import( + { project_token: 'MY_PROJECT_TOKEN' }, + 'TEST ID', 'Test Event', + { 'Circumstances' => 'During a test', 'time' => older_time.to_i } + ) expect(@log).to eq([[:import, { - 'api_key' => 'API_KEY', + 'credentials' => { + 'type' => 'project_token', + 'token' => 'MY_PROJECT_TOKEN', + }, 'data' => { 'event' => 'Test Event', 'properties' => { @@ -71,6 +107,53 @@ 'time' => older_time.to_i, } } - } ]]) + }]]) + end + + it 'should accept string keys in credentials hash' do + @events.import( + { 'service_account_username' => 'sa@serviceaccount.mixpanel.com', + 'service_account_password' => 'sa-secret', + 'project_id' => '12345' }, + 'TEST ID', 'Test Event', { 'Circumstances' => 'During a test' } + ) + expect(@log.first.first).to eq(:import) + expect(@log.first.last.dig('credentials', 'type')).to eq('service_account') + end + + it 'should raise ArgumentError with a clear message when credentials is a String' do + expect { + @events.import('OLD_API_KEY', 'TEST ID', 'Test Event') + }.to raise_error(ArgumentError, /credentials must be a Hash.*got String/) + end + + it 'should raise ArgumentError with a clear message when credentials is nil' do + expect { + @events.import(nil, 'TEST ID', 'Test Event') + }.to raise_error(ArgumentError, /credentials must be a Hash.*got NilClass/) + end + + it 'should raise ArgumentError when no recognised credential key is provided' do + expect { + @events.import({}, 'TEST ID', 'Test Event') + }.to raise_error(ArgumentError, /credentials must include/) + end + + it 'should raise ArgumentError when service account is missing password or project_id' do + expect { + @events.import({ service_account_username: 'u' }, 'TEST ID', 'Test Event') + }.to raise_error(ArgumentError, /service account credentials missing required fields: service_account_password, project_id/) + end + + it 'should raise ArgumentError when service account has blank project_id' do + expect { + @events.import({ service_account_username: 'u', service_account_password: 'p', project_id: '' }, 'TEST ID', 'Test Event') + }.to raise_error(ArgumentError, /service account credentials missing required fields: project_id/) + end + + it 'should raise ArgumentError when project_token is blank' do + expect { + @events.import({ project_token: '' }, 'TEST ID', 'Test Event') + }.to raise_error(ArgumentError, /:project_token must not be blank/) end end diff --git a/spec/mixpanel-ruby/tracker_spec.rb b/spec/mixpanel-ruby/tracker_spec.rb index 1fe54eb..571074a 100644 --- a/spec/mixpanel-ruby/tracker_spec.rb +++ b/spec/mixpanel-ruby/tracker_spec.rb @@ -82,7 +82,7 @@ messages << [type, JSON.load(message)] end mixpanel.track('ID', 'Event') - mixpanel.import('API_KEY', 'ID', 'Import') + mixpanel.import({ project_token: 'MY_TOKEN' }, 'ID', 'Import') mixpanel.people.set('ID', {'k' => 'v'}) mixpanel.people.append('ID', {'k' => 'v'}) @@ -109,7 +109,7 @@ 'time' => @time_now.to_i * 1000 } }, - 'api_key' => 'API_KEY', + 'credentials' => { 'type' => 'project_token', 'token' => 'MY_TOKEN' }, } ], [ :profile_update, 'data' =>