├── .rspec ├── lib ├── simple_segment │ ├── version.rb │ ├── logging.rb │ ├── operations │ │ ├── identify.rb │ │ ├── page.rb │ │ ├── alias.rb │ │ ├── group.rb │ │ ├── track.rb │ │ └── operation.rb │ ├── operations.rb │ ├── configuration.rb │ ├── utils.rb │ ├── batch.rb │ ├── request.rb │ └── client.rb └── simple_segment.rb ├── Gemfile ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── spec ├── simple_segment_spec.rb ├── spec_helper.rb └── simple_segment │ ├── configuration_spec.rb │ ├── request_spec.rb │ ├── operations │ └── track_spec.rb │ ├── batch_spec.rb │ └── client_spec.rb ├── .rubocop.yml ├── .github └── workflows │ └── ci.yml ├── LICENSE.txt ├── simple_segment.gemspec ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /lib/simple_segment/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | VERSION = '1.6.0' 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in simple_segment.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.gem 11 | .rbenv-gemsets 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /lib/simple_segment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | require 'json' 5 | require 'time' 6 | require 'simple_segment/version' 7 | require 'simple_segment/client' 8 | 9 | module SimpleSegment 10 | end 11 | -------------------------------------------------------------------------------- /spec/simple_segment_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SimpleSegment do 6 | it 'has a version number' do 7 | expect(SimpleSegment::VERSION).not_to be nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | require 'simple_segment' 5 | require 'webmock/rspec' 6 | require 'timecop' 7 | require 'pry' 8 | 9 | WebMock.disable_net_connect! 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'simple_segment' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'pry' 11 | Pry.start 12 | -------------------------------------------------------------------------------- /lib/simple_segment/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | module SimpleSegment 5 | module Logging 6 | def self.included(klass) 7 | klass.extend(self) 8 | end 9 | 10 | def default_logger(logger_option) 11 | logger_option || Logger.new($stdout) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Metrics/BlockLength: 5 | Exclude: 6 | - '**/*_spec.rb' 7 | 8 | Layout/LineLength: 9 | Max: 100 10 | IgnoreCopDirectives: true 11 | Exclude: 12 | - 'simple_segment.gemspec' 13 | 14 | Style/Documentation: 15 | Enabled: false 16 | 17 | Gemspec/RequiredRubyVersion: 18 | Enabled: false 19 | 20 | Gemspec/DevelopmentDependencies: 21 | Enabled: false -------------------------------------------------------------------------------- /lib/simple_segment/operations/identify.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | module Operations 5 | class Identify < Operation 6 | def call 7 | request.post('/v1/identify', build_payload) 8 | end 9 | 10 | def build_payload 11 | base_payload.merge( 12 | traits: options[:traits] && isoify_dates!(options[:traits]) 13 | ) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/simple_segment/operations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simple_segment/request' 4 | require 'simple_segment/operations/operation' 5 | require 'simple_segment/operations/identify' 6 | require 'simple_segment/operations/track' 7 | require 'simple_segment/operations/page' 8 | require 'simple_segment/operations/group' 9 | require 'simple_segment/operations/alias' 10 | 11 | module SimpleSegment 12 | module Operations 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/simple_segment/operations/page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | module Operations 5 | class Page < Operation 6 | def call 7 | request.post('/v1/page', build_payload) 8 | end 9 | 10 | def build_payload 11 | properties = options[:properties] && isoify_dates!(options[:properties]) 12 | 13 | base_payload.merge( 14 | name: options[:name], 15 | properties: properties 16 | ) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/simple_segment/operations/alias.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | module Operations 5 | class Alias < Operation 6 | def call 7 | request.post('/v1/alias', build_payload) 8 | end 9 | 10 | def build_payload 11 | raise ArgumentError, 'previous_id must be present' \ 12 | unless options[:previous_id] 13 | 14 | base_payload.merge( 15 | previousId: options[:previous_id] 16 | ) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | ruby: ['3.4', '3.3', '3.2'] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Ruby ${{ matrix.ruby }} 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby }} 20 | bundler-cache: true 21 | - name: Run tests 22 | run: bundle exec rake -------------------------------------------------------------------------------- /lib/simple_segment/operations/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | module Operations 5 | class Group < Operation 6 | def call 7 | request.post('/v1/group', build_payload) 8 | end 9 | 10 | def build_payload 11 | raise ArgumentError, 'group_id must be present' \ 12 | unless options[:group_id] 13 | 14 | base_payload.merge( 15 | traits: options[:traits] && isoify_dates!(options[:traits]), 16 | groupId: options[:group_id] 17 | ) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/simple_segment/operations/track.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | module Operations 5 | class Track < Operation 6 | def call 7 | request.post('/v1/track', build_payload) 8 | end 9 | 10 | def build_payload 11 | raise ArgumentError, 'event name must be present' unless options[:event] 12 | 13 | properties = options[:properties] || {} 14 | 15 | base_payload.merge( 16 | event: options[:event], 17 | properties: isoify_dates!(properties) 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/simple_segment/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simple_segment/logging' 4 | 5 | module SimpleSegment 6 | class Configuration 7 | include SimpleSegment::Utils 8 | include SimpleSegment::Logging 9 | 10 | DEFAULT_HOST = 'api.segment.io' 11 | 12 | attr_reader :write_key, :on_error, :stub, :logger, :http_options, :host 13 | 14 | def initialize(settings = {}) 15 | symbolized_settings = symbolize_keys(settings) 16 | @write_key = symbolized_settings[:write_key] 17 | @on_error = symbolized_settings[:on_error] || proc {} 18 | @stub = symbolized_settings[:stub] 19 | @logger = default_logger(symbolized_settings[:logger]) 20 | @http_options = { use_ssl: true } 21 | .merge(symbolized_settings[:http_options] || {}) 22 | @host = symbolized_settings[:host] || DEFAULT_HOST 23 | raise ArgumentError, 'Missing required option :write_key' \ 24 | unless @write_key 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/simple_segment/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | module Utils 5 | def self.included(klass) 6 | klass.extend(self) 7 | end 8 | 9 | def symbolize_keys(hash) 10 | hash.transform_keys(&:to_sym) 11 | end 12 | 13 | # public: Converts all the date values in the into iso8601 strings in place 14 | # 15 | def isoify_dates!(hash) 16 | hash.replace isoify_dates hash 17 | end 18 | 19 | # public: Returns a new hash with all the date values in the into iso8601 20 | # strings 21 | # 22 | def isoify_dates(hash) 23 | hash.transform_values do |v| 24 | maybe_datetime_in_iso8601(v) 25 | end 26 | end 27 | 28 | def maybe_datetime_in_iso8601(prop) 29 | case prop 30 | when Time 31 | prop.iso8601(3) 32 | when DateTime 33 | prop.to_time.iso8601(3) 34 | when Date 35 | prop.strftime('%F') 36 | else 37 | prop 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mikhail Topolskiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /simple_segment.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'simple_segment/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'simple_segment' 9 | spec.version = SimpleSegment::VERSION 10 | spec.authors = ['Mikhail Topolskiy'] 11 | spec.email = ['mikhail.topolskiy@gmail.com'] 12 | 13 | spec.summary = 'A simple synchronous API client for segment.io.' 14 | spec.description = 'SimpleSegment allows for manual control of when and how the events are sent to Segment.' 15 | spec.homepage = 'https://github.com/whatthewhat/simple_segment' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_development_dependency 'bundler', '>= 1.11' 24 | spec.add_development_dependency 'pry' 25 | spec.add_development_dependency 'rake', '>= 10.0' 26 | spec.add_development_dependency 'rspec', '~> 3.0' 27 | spec.add_development_dependency 'rubocop', '1.65.0' 28 | spec.add_development_dependency 'timecop', '~> 0.9.5' 29 | spec.add_development_dependency 'webmock', '~> 3.7' 30 | spec.metadata = { 31 | 'rubygems_mfa_required' => 'true' 32 | 'changelog_uri' => 'https://github.com/whatthewhat/simple_segment/blob/master/CHANGELOG.md', 33 | } 34 | end 35 | -------------------------------------------------------------------------------- /lib/simple_segment/batch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | class Batch 5 | include SimpleSegment::Utils 6 | 7 | attr_reader :client, :payload 8 | 9 | def self.deserialize(client, payload) 10 | new(client, symbolize_keys(payload)) 11 | end 12 | 13 | def initialize(client, payload = { batch: [] }) 14 | @client = client 15 | @payload = payload 16 | end 17 | 18 | def identify(options) 19 | add(Operations::Identify, options, __method__) 20 | end 21 | 22 | def track(options) 23 | add(Operations::Track, options, __method__) 24 | end 25 | 26 | def page(options) 27 | add(Operations::Page, options, __method__) 28 | end 29 | 30 | def group(options) 31 | add(Operations::Group, options, __method__) 32 | end 33 | 34 | def context=(context) 35 | payload[:context] = context 36 | end 37 | 38 | def integrations=(integrations) 39 | payload[:integrations] = integrations 40 | end 41 | 42 | def serialize 43 | payload 44 | end 45 | 46 | def commit 47 | raise ArgumentError, 'A batch must contain at least one action' if payload[:batch].empty? 48 | 49 | Request.new(client).post('/v1/batch', payload) 50 | end 51 | 52 | private 53 | 54 | def add(operation_class, options, action) 55 | operation = operation_class.new(client, symbolize_keys(options)) 56 | operation_payload = operation.build_payload 57 | operation_payload[:type] = action 58 | payload[:batch] << operation_payload 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/simple_segment/operations/operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | module Operations 5 | class Operation 6 | include SimpleSegment::Utils 7 | 8 | DEFAULT_CONTEXT = { 9 | library: { 10 | name: 'simple_segment', 11 | version: SimpleSegment::VERSION 12 | } 13 | }.freeze 14 | 15 | def initialize(client, options = {}) 16 | @options = options 17 | @context = DEFAULT_CONTEXT.merge(options[:context].to_h) 18 | @request = Request.new(client) 19 | end 20 | 21 | def call 22 | raise 'Must be implemented in a subclass' 23 | end 24 | 25 | private 26 | 27 | attr_reader :options, :request, :context 28 | 29 | def base_payload 30 | check_identity! 31 | current_time = Time.now 32 | 33 | { 34 | userId: options[:user_id], 35 | anonymousId: options[:anonymous_id], 36 | context: context, 37 | integrations: options[:integrations], 38 | timestamp: timestamp(options.fetch(:timestamp, current_time)), 39 | sentAt: current_time.iso8601(3), 40 | messageId: options[:message_id] 41 | } 42 | end 43 | 44 | def check_identity! 45 | raise ArgumentError, 'user_id or anonymous_id must be present' \ 46 | unless options[:user_id] || options[:anonymous_id] 47 | end 48 | 49 | def timestamp(timestamp) 50 | if timestamp.respond_to?(:iso8601) 51 | timestamp.iso8601(3) 52 | else 53 | Time.iso8601(timestamp).iso8601(3) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/simple_segment/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleSegment 4 | class Request 5 | DEFAULT_HEADERS = { 6 | 'Content-Type' => 'application/json', 7 | 'accept' => 'application/json' 8 | }.freeze 9 | 10 | attr_reader :write_key, :error_handler, :stub, :logger, :http_options, :host 11 | 12 | def initialize(client) 13 | @write_key = client.config.write_key 14 | @error_handler = client.config.on_error 15 | @stub = client.config.stub 16 | @logger = client.config.logger 17 | @http_options = client.config.http_options 18 | @host = client.config.host 19 | end 20 | 21 | def post(path, payload, headers: DEFAULT_HEADERS) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 22 | response = nil 23 | status_code = nil 24 | response_body = nil 25 | 26 | uri = URI("https://#{host}#{path}") 27 | payload = JSON.generate(payload) 28 | if stub 29 | logger.debug "stubbed request to \ 30 | #{uri.path}: write key = #{write_key}, \ 31 | payload = #{payload}" 32 | 33 | { status: 200, error: nil } 34 | else 35 | Net::HTTP.start(uri.host, uri.port, :ENV, http_options) do |http| 36 | request = Net::HTTP::Post.new(uri.path, headers) 37 | request.basic_auth write_key, nil 38 | http.request(request, payload).tap do |res| 39 | status_code = res.code 40 | response_body = res.body 41 | response = res 42 | response.value 43 | end 44 | end 45 | end 46 | rescue StandardError => e 47 | error_handler.call(status_code, response_body, e, response) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/simple_segment/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SimpleSegment::Configuration do 6 | it 'requires a write_key' do 7 | expect do 8 | described_class.new(write_key: nil) 9 | end.to raise_error(ArgumentError) 10 | end 11 | 12 | it 'works with symbol keys' do 13 | config = described_class.new(write_key: 'test') 14 | expect(config.write_key).to eq 'test' 15 | end 16 | 17 | it 'works with string keys' do 18 | config = described_class.new('write_key' => 'key') 19 | expect(config.write_key).to eq 'key' 20 | end 21 | 22 | context 'defaults' do 23 | it 'has a default error handler' do 24 | config = described_class.new(write_key: 'test') 25 | expect(config.on_error).to be_a(Proc) 26 | end 27 | 28 | it 'has a default http_options' do 29 | config = described_class.new(write_key: 'test') 30 | expect(config.http_options).to eq(use_ssl: true) 31 | end 32 | 33 | it 'has a default host' do 34 | config = described_class.new(write_key: 'test') 35 | expect(config.host).to eq('api.segment.io') 36 | end 37 | end 38 | 39 | it 'works with stub' do 40 | config = described_class.new(write_key: 'test', stub: true) 41 | expect(config.stub).to eq true 42 | end 43 | 44 | it 'works with user prefered logging' do 45 | my_logger = object_double('Logger') 46 | config = described_class.new( 47 | write_key: 'test', 48 | logger: my_logger 49 | ) 50 | expect(config.logger).to eq(my_logger) 51 | end 52 | 53 | it 'accepts an http_options' do 54 | config = described_class.new(write_key: 'test', http_options: { read_timeout: 42 }) 55 | expect(config.http_options).to eq(use_ssl: true, read_timeout: 42) 56 | end 57 | 58 | it 'accepts a host' do 59 | config = described_class.new(write_key: 'test', host: 'events.eu1.segmentapis.com') 60 | expect(config.host).to eq('events.eu1.segmentapis.com') 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/simple_segment/request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SimpleSegment::Request do 6 | context 'API errors handling' do 7 | before(:example) do 8 | stub_request(:post, 'https://api.segment.io/v1/track') 9 | .with(basic_auth: ['key', '']) 10 | .to_return(status: 500, body: { error: 'Does not compute' }.to_json) 11 | end 12 | 13 | it 'does not raise an error with default client' do 14 | client = SimpleSegment::Client.new(write_key: 'key') 15 | expect do 16 | described_class.new(client).post('/v1/track', {}) 17 | end.not_to raise_error 18 | end 19 | 20 | it 'passes http errors to the on_error hook' do 21 | error_code = nil 22 | error_body = nil 23 | response = nil 24 | exception = nil 25 | error_handler = proc do |code, body, res, e| 26 | error_code = code 27 | error_body = body 28 | response = res 29 | exception = e 30 | end 31 | client = SimpleSegment::Client.new( 32 | write_key: 'key', 33 | on_error: error_handler 34 | ) 35 | described_class.new(client).post('/v1/track', {}) 36 | 37 | expect(error_code).to eq('500') 38 | expect(error_body).to eq({ error: 'Does not compute' }.to_json) 39 | expect(response).to be_a(Net::HTTPFatalError) 40 | expect(exception).to be_a(Net::HTTPInternalServerError) 41 | end 42 | end 43 | 44 | context 'with custom host' do 45 | it 'uses configured host' do 46 | request_stub = stub_request(:post, 'https://events.eu1.segmentapis.com/v1/track') 47 | .with(basic_auth: ['key', '']) 48 | .to_return(status: 200) 49 | 50 | client = SimpleSegment::Client.new( 51 | write_key: 'key', 52 | host: 'events.eu1.segmentapis.com' 53 | ) 54 | described_class.new(client).post('/v1/track', {}) 55 | 56 | expect(request_stub).to have_been_requested.once 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/simple_segment/operations/track_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SimpleSegment::Operations::Track do 6 | describe '#build_payload' do 7 | let(:client) { SimpleSegment::Client.new(write_key: 'key') } 8 | 9 | it 'uses an empty hash if no properties were provided' do 10 | payload = described_class.new( 11 | client, 12 | event: 'event', 13 | user_id: 'id' 14 | ).build_payload 15 | 16 | expect(payload[:properties]).to eq({}) 17 | end 18 | 19 | context 'timestamps' do 20 | it 'adds timestamp when it is not provided' do 21 | Timecop.freeze(Time.new(2025, 11, 26, 20, 5, 54.456, 'UTC')) do 22 | payload = described_class.new( 23 | client, 24 | event: 'event', 25 | user_id: 'id' 26 | ).build_payload 27 | 28 | expect(payload[:timestamp]).to eq('2025-11-26T20:05:54.456Z') 29 | end 30 | end 31 | 32 | it 'works with Time objects' do 33 | payload = described_class.new( 34 | client, 35 | event: 'event', 36 | user_id: 'id', 37 | timestamp: Time.new(2016, 6, 27, 23, 4, 20, '+03:00') 38 | ).build_payload 39 | 40 | expect(payload[:timestamp]).to eq('2016-06-27T23:04:20.000+03:00') 41 | end 42 | 43 | it 'works with millisecond-precision Time objects' do 44 | payload = described_class.new( 45 | client, 46 | event: 'event', 47 | user_id: 'id', 48 | timestamp: Time.new(2025, 11, 26, 20, 1, 10.567, 'UTC') 49 | ).build_payload 50 | 51 | expect(payload[:timestamp]).to eq('2025-11-26T20:01:10.567Z') 52 | end 53 | 54 | it 'works with iso8601 strings' do 55 | payload = described_class.new( 56 | client, 57 | event: 'event', 58 | user_id: 'id', 59 | timestamp: '2016-06-27T20:04:20Z' 60 | ).build_payload 61 | 62 | expect(payload[:timestamp]).to eq('2016-06-27T20:04:20.000Z') 63 | end 64 | 65 | it 'works with millisecond-precision iso8601 strings' do 66 | payload = described_class.new( 67 | client, 68 | event: 'event', 69 | user_id: 'id', 70 | timestamp: '2025-11-26T19:54:10.123Z' 71 | ).build_payload 72 | 73 | expect(payload[:timestamp]).to eq('2025-11-26T19:54:10.123Z') 74 | end 75 | 76 | it 'errors with invalid strings' do 77 | expect do 78 | described_class.new( 79 | client, 80 | event: 'event', 81 | user_id: 'id', 82 | timestamp: '2016 06 27T23:04:20' 83 | ).build_payload 84 | end.to raise_error(ArgumentError) 85 | end 86 | 87 | it 'works with stubed calls' do 88 | stubed_client = SimpleSegment::Client.new(write_key: 'key', stub: true) 89 | expect(stubed_client.track( 90 | event: 'event', 91 | user_id: 'id', 92 | timestamp: Time.new(2016, 6, 27, 23, 4, 20, '+03:00') 93 | )[:status]).to eq(200) 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.6.0] - 2025-12-03 10 | 11 | ### Changed 12 | - Use millisecond precision for `timestamp` and `sentAt` fields https://github.com/whatthewhat/simple_segment/pull/42 by [@marcboquet](https://github.com/marcboquet) 13 | 14 | ## [1.5.0] - 2022-12-06 15 | 16 | ### Changed 17 | - Use the /v1/batch endpoint for batching events (this should be backward compatible according to Segment) https://github.com/whatthewhat/simple_segment/pull/40 by [@sgallag-insta](https://github.com/sgallag-insta) 18 | 19 | ## [1.4.0] - 2022-08-31 20 | 21 | ### Added 22 | - Add host option to support regional segments https://github.com/whatthewhat/simple_segment/pull/37 by [@larsklevan](https://github.com/larsklevan) 23 | 24 | ## [1.3.0] - 2021-11-27 25 | 26 | ### Added 27 | - Add support for message_id override https://github.com/whatthewhat/simple_segment/pull/34 by [@theblang](https://github.com/theblang) 28 | 29 | ## [1.2.0] - 2020-07-29 30 | 31 | ### Changed 32 | - Use an empty hash if no properties were provided to `track` https://github.com/whatthewhat/simple_segment/pull/28 33 | 34 | ## [1.1.0] - 2020-04-11 35 | 36 | ### Added 37 | - Added support for http_proxy and https_proxy environment variables https://github.com/whatthewhat/simple_segment/pull/26 by [@saks](https://github.com/saks) 38 | - Added Ruby 2.7 to travis.yml 39 | 40 | ## [1.0.0] - 2019-12-12 41 | 42 | ### Added 43 | - Allow passing custom Net::HTTP options (e.g. timeout) https://github.com/whatthewhat/simple_segment/pull/23 by [@barodeur](https://github.com/barodeur) 44 | 45 | ### Changed 46 | - The gem is no longer tested with Ruby versions below 2.4 47 | 48 | ## [0.3.0] - 2018-03-15 49 | 50 | ### Changed 51 | - Date properties are now automatically converted to ISO 8601 to be consistent with the official client https://github.com/whatthewhat/simple_segment/pull/19 by @juanramoncg 52 | 53 | [Unreleased]: https://github.com/whatthewhat/simple_segment/compare/v1.6.0...HEAD 54 | [1.6.0]: https://github.com/whatthewhat/simple_segment/compare/v1.5.0...v1.6.0 55 | [1.5.0]: https://github.com/whatthewhat/simple_segment/compare/v1.4.0...v1.5.0 56 | [1.4.0]: https://github.com/whatthewhat/simple_segment/compare/v1.3.0...v1.4.0 57 | [1.3.0]: https://github.com/whatthewhat/simple_segment/compare/v1.2.0...v1.3.0 58 | [1.2.0]: https://github.com/whatthewhat/simple_segment/compare/v1.1.0...v1.2.0 59 | [1.1.0]: https://github.com/whatthewhat/simple_segment/compare/v1.0.0...v1.1.0 60 | [1.0.0]: https://github.com/whatthewhat/simple_segment/compare/v0.3.0...v1.0.0 61 | [0.3.0]: https://github.com/whatthewhat/simple_segment/compare/v0.2.1...v0.3.0 62 | [0.2.1]: https://github.com/whatthewhat/simple_segment/compare/v0.2.0...v0.2.1 63 | [0.2.0]: https://github.com/whatthewhat/simple_segment/compare/v0.1.1...v0.2.0 64 | [0.1.1]: https://github.com/whatthewhat/simple_segment/compare/v0.1.0...v0.1.1 65 | [0.1.0]: https://github.com/whatthewhat/simple_segment/compare/2d62f07a1df8388000b0b5a20331409132d05ad3...v0.1.0 66 | -------------------------------------------------------------------------------- /lib/simple_segment/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simple_segment/utils' 4 | require 'simple_segment/configuration' 5 | require 'simple_segment/operations' 6 | require 'simple_segment/batch' 7 | 8 | module SimpleSegment 9 | class Client 10 | include SimpleSegment::Utils 11 | 12 | attr_reader :config 13 | 14 | def initialize(options = {}) 15 | @config = Configuration.new(options) 16 | end 17 | 18 | # @param [Hash] options 19 | # @option :user_id 20 | # @option :anonymous_id 21 | # @option :traits [Hash] 22 | # @option :context [Hash] 23 | # @option :integrations [Hash] 24 | # @option :timestamp [#iso8601] (Time.now) 25 | # @option :message_id 26 | def identify(options) 27 | Operations::Identify.new(self, symbolize_keys(options)).call 28 | end 29 | 30 | # @param [Hash] options 31 | # @option :event [String] required 32 | # @option :user_id 33 | # @option :anonymous_id 34 | # @option :properties [Hash] 35 | # @option :context [Hash] 36 | # @option :integrations [Hash] 37 | # @option :timestamp [#iso8601] (Time.now) 38 | # @option :message_id 39 | def track(options) 40 | Operations::Track.new(self, symbolize_keys(options)).call 41 | end 42 | 43 | # @param [Hash] options 44 | # @option :user_id 45 | # @option :anonymous_id 46 | # @option :name [String] 47 | # @option :properties [Hash] 48 | # @option :context [Hash] 49 | # @option :integrations [Hash] 50 | # @option :timestamp [#iso8601] (Time.now) 51 | # @option :message_id 52 | def page(options) 53 | Operations::Page.new(self, symbolize_keys(options)).call 54 | end 55 | 56 | # @param [Hash] options 57 | # @option :user_id 58 | # @option :anonymous_id 59 | # @option :group_id required 60 | # @option :traits [Hash] 61 | # @option :context [Hash] 62 | # @option :integrations [Hash] 63 | # @option :timestamp [#iso8601] (Time.now) 64 | # @option :message_id 65 | def group(options) 66 | Operations::Group.new(self, symbolize_keys(options)).call 67 | end 68 | 69 | # @param [Hash] options 70 | # @option :user_id 71 | # @option :anonymous_id 72 | # @option :previous_id required 73 | # @option :traits [Hash] 74 | # @option :context [Hash] 75 | # @option :integrations [Hash] 76 | # @option :timestamp [#iso8601] (Time.now) 77 | # @option :message_id 78 | def alias(options) 79 | Operations::Alias.new(self, symbolize_keys(options)).call 80 | end 81 | 82 | # @yield [batch] Yields a special batch object that can be used to group 83 | # `identify`, `track`, `page` and `group` calls into a 84 | # single API request. 85 | # @example 86 | # client.batch do |analytics| 87 | # analytics.context = { 'foo' => 'bar' } 88 | # analytics.identify(user_id: 'id') 89 | # analytics.track(event: 'Delivered Package', user_id: 'id') 90 | # end 91 | def batch 92 | batch = Batch.new(self) 93 | yield(batch) 94 | batch.commit 95 | end 96 | 97 | # A no op, added for backwards compatibility with `analytics-ruby` 98 | def flush; end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/simple_segment/batch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SimpleSegment::Batch do 6 | let(:client) { SimpleSegment::Client.new(write_key: 'key') } 7 | 8 | it 'supports identify, group, track and page' do 9 | request_stub = stub_request(:post, 'https://api.segment.io/v1/batch') 10 | .with do |request| 11 | batch = JSON.parse(request.body)['batch'] 12 | batch.map do |operation| 13 | operation['type'] 14 | end == %w[identify group track page] 15 | end 16 | 17 | batch = described_class.new(client) 18 | batch.identify(user_id: 'id') 19 | batch.group(user_id: 'id', group_id: 'group_id') 20 | batch.track(event: 'Delivered Package', user_id: 'id') 21 | batch.page( 22 | user_id: 'id', 23 | properties: { url: 'https://en.wikipedia.org/wiki/Zoidberg' } 24 | ) 25 | batch.commit 26 | 27 | expect(request_stub).to have_been_requested.once 28 | end 29 | 30 | it 'allows to set common context' do 31 | expected_context = { 'foo' => 'bar' } 32 | request_stub = stub_request(:post, 'https://api.segment.io/v1/batch') 33 | .with do |request| 34 | context = JSON.parse(request.body)['context'] 35 | context == expected_context 36 | end 37 | 38 | batch = described_class.new(client) 39 | batch.context = expected_context 40 | batch.track(event: 'Delivered Package', user_id: 'id') 41 | batch.commit 42 | 43 | expect(request_stub).to have_been_requested.once 44 | end 45 | 46 | it 'allows to set common integrations' do 47 | expected_integrations = { 'foo' => 'bar' } 48 | request_stub = stub_request(:post, 'https://api.segment.io/v1/batch') 49 | .with do |request| 50 | integrations = JSON.parse(request.body)['integrations'] 51 | integrations == expected_integrations 52 | end 53 | 54 | batch = described_class.new(client) 55 | batch.integrations = expected_integrations 56 | batch.track(event: 'Delivered Package', user_id: 'id') 57 | batch.commit 58 | 59 | expect(request_stub).to have_been_requested.once 60 | end 61 | 62 | it 'validates event payload' do 63 | batch = described_class.new(client) 64 | 65 | expect { batch.track(event: nil) }.to raise_error(ArgumentError) 66 | end 67 | 68 | it 'errors when trying to commit an empty batch' do 69 | batch = described_class.new(client) 70 | 71 | expect { batch.commit }.to raise_error(ArgumentError) 72 | end 73 | 74 | it 'can be serialized and deserialized' do 75 | request_stub = stub_request(:post, 'https://api.segment.io/v1/batch') 76 | .with do |request| 77 | batch = JSON.parse(request.body)['batch'] 78 | batch.map do |operation| 79 | operation['type'] 80 | end == %w[identify track] 81 | end 82 | 83 | batch = described_class.new(client) 84 | batch.identify(user_id: 'id') 85 | batch.track(event: 'Delivered Package', user_id: 'id') 86 | serialized_batch = batch.serialize 87 | described_class.deserialize(client, serialized_batch).commit 88 | 89 | expect(request_stub).to have_been_requested.once 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleSegment 2 | 3 | ![Build Status](https://github.com/whatthewhat/simple_segment/actions/workflows/ci.yml/badge.svg?branch=master) 4 | 5 | A simple synchronous Ruby API client for [segment.io](https://segment.io). 6 | 7 | SimpleSegment allows for manual control of when and how the events are sent to Segment. This can be useful if you want to leverage an existing queueing system like Sidekiq or Resque for sending events or need to send events synchronously. If this is not the case you will be better off using the [official segment gem](https://github.com/segmentio/analytics-ruby) that handles queuing for you. 8 | 9 | ## Status 10 | 11 | The gem supports all existing functionality of analytics-ruby: 12 | 13 | - `analytics.track(...)` 14 | - `analytics.identify(...)` 15 | - `analytics.group(...)` 16 | - `analytics.page(...)` 17 | - `analytics.alias(...)` 18 | - `analytics.flush` (no op for backwards compatibility with the official gem) 19 | 20 | In addition, it offers the ability to manually batch events with [analytics.batch](#batching). 21 | 22 | The plan is to be a drop-in replacement for the official gem, so if you find inconsistencies with `analytics-ruby` feel free to file an issue. 23 | 24 | ## Installation 25 | 26 | Add this line to your application's Gemfile: 27 | 28 | ```ruby 29 | gem 'simple_segment' 30 | ``` 31 | 32 | Or install it yourself as: 33 | 34 | ```sh 35 | $ gem install simple_segment 36 | ``` 37 | 38 | ## Usage 39 | 40 | Create a client instance: 41 | 42 | ```ruby 43 | analytics = SimpleSegment::Client.new( 44 | write_key: 'YOUR_WRITE_KEY', # required 45 | on_error: proc { |error_code, error_body, exception, response| 46 | # defaults to an empty proc 47 | } 48 | ) 49 | ``` 50 | 51 | Use it as you would use `analytics-ruby`: 52 | 53 | ```ruby 54 | analytics.track( 55 | user_id: user.id, 56 | event: 'Created Account' 57 | ) 58 | ``` 59 | 60 | ### Batching 61 | 62 | You can manually batch events with `analytics.batch`: 63 | 64 | ```ruby 65 | analytics.batch do |batch| 66 | batch.context = {...} # shared context for all events 67 | batch.integrations = {...} # shared integrations hash for all events 68 | batch.identify(...) 69 | batch.track(...) 70 | batch.track(...) 71 | ... 72 | end 73 | ``` 74 | 75 | ### Stub API calls 76 | 77 | You can stub your API calls avoiding unecessary requests in development and automated test environments (backwards compatible with the official gem): 78 | 79 | ```ruby 80 | analytics = SimpleSegment::Client.new( 81 | write_key: 'YOUR_WRITE_KEY', 82 | stub: true 83 | ) 84 | ``` 85 | 86 | ### Configurable Logger 87 | 88 | When used in stubbed mode all calls are logged to STDOUT, this can be changed by providing a custom logger object: 89 | 90 | ```ruby 91 | analytics = SimpleSegment::Client.new( 92 | write_key: 'YOUR_WRITE_KEY', 93 | logger: Rails.logger 94 | ) 95 | ``` 96 | 97 | ### Set HTTP Options 98 | 99 | You can set [options](https://docs.ruby-lang.org/en/2.0.0/Net/HTTP.html#method-c-start) that are passed to `Net::HTTP.start`. 100 | 101 | ```ruby 102 | analytics = SimpleSegment::Client.new( 103 | write_key: 'YOUR_WRITE_KEY', 104 | http_options: { 105 | open_timeout: 42, 106 | read_timeout: 42, 107 | close_on_empty_response: true, 108 | # ... 109 | } 110 | ) 111 | ``` 112 | 113 | ### Configurable Host 114 | 115 | You can use [regional segments](https://segment.com/docs/guides/regional-segment/) and send data to the desired region by setting the `host` parameter. 116 | 117 | ```ruby 118 | analytics = SimpleSegment::Client.new( 119 | write_key: 'YOUR_WRITE_KEY', 120 | host: 'events.eu1.segmentapis.com' 121 | ) 122 | ``` 123 | 124 | ## Development 125 | 126 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 127 | 128 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 129 | 130 | ## Contributing 131 | 132 | Bug reports and pull requests are welcome on GitHub at https://github.com/whatthewhat/simple_segment. 133 | 134 | 135 | ## License 136 | 137 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 138 | -------------------------------------------------------------------------------- /spec/simple_segment/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SimpleSegment::Client do 6 | subject(:client) do 7 | described_class.new(write_key: 'WRITE_KEY') 8 | end 9 | let(:now) { Time.new(2999, 12, 29) } 10 | 11 | describe '#identify' do 12 | it 'sends identity and properties to segment' do 13 | time = Time.utc(2018, 3, 11, 10, 20) 14 | dt = DateTime.new(2018, 3, 11, 12, 20) 15 | date = Date.new(2018, 3, 12) 16 | 17 | options = { 18 | user_id: 'id', 19 | traits: { 20 | name: 'Philip J. Fry', 21 | occupation: 'Delivery Boy', 22 | foo_time: time, 23 | foo_date_time: dt, 24 | foo_date: date 25 | }, 26 | context: { 27 | employer: 'Planet Express' 28 | }, 29 | integrations: { 30 | all: true 31 | }, 32 | timestamp: Time.new(2016, 3, 23), 33 | message_id: 'message-id' 34 | } 35 | expected_request_body = { 36 | 'userId' => 'id', 37 | 'anonymousId' => nil, 38 | 'traits' => { 39 | 'name' => 'Philip J. Fry', 40 | 'occupation' => 'Delivery Boy', 41 | 'foo_time' => '2018-03-11T10:20:00.000Z', 42 | 'foo_date_time' => dt.to_time.iso8601(3), 43 | 'foo_date' => '2018-03-12' 44 | }, 45 | 'context' => { 46 | 'employer' => 'Planet Express', 47 | 'library' => { 48 | 'name' => 'simple_segment', 49 | 'version' => SimpleSegment::VERSION 50 | } 51 | }, 52 | 'integrations' => { 53 | 'all' => true 54 | }, 55 | 'timestamp' => Time.new(2016, 3, 23).iso8601(3), 56 | 'sentAt' => now.iso8601(3), 57 | 'messageId' => 'message-id' 58 | } 59 | request_stub = stub_request(:post, 'https://api.segment.io/v1/identify') 60 | .with(body: expected_request_body, basic_auth: ['WRITE_KEY', '']) 61 | 62 | Timecop.freeze(now) do 63 | client.identify(options) 64 | expect(request_stub).to have_been_requested.once 65 | end 66 | end 67 | 68 | context 'input checks' do 69 | before(:example) do 70 | stub_request(:post, 'https://api.segment.io/v1/identify') 71 | .with(basic_auth: ['WRITE_KEY', '']) 72 | end 73 | 74 | it 'errors with user_id and anonymous_id blank' do 75 | expect { client.identify }.to raise_error(ArgumentError) 76 | end 77 | 78 | it 'allows blank user_id if anonymous_id is present' do 79 | expect do 80 | client.identify(anonymous_id: 'id') 81 | end.not_to raise_error 82 | end 83 | 84 | it 'allows timestamp to be a string' do 85 | expect do 86 | client.identify( 87 | anonymous_id: 'id', 88 | timestamp: Time.new(2016, 3, 23).iso8601.to_s 89 | ) 90 | end.not_to raise_error 91 | end 92 | end 93 | end 94 | 95 | describe '#track' do 96 | it 'sends event and properties to segment' do 97 | time = Time.utc(2018, 3, 11, 10, 20) 98 | dt = DateTime.new(2018, 3, 11, 12, 20) 99 | date = Date.new(2018, 3, 12) 100 | 101 | options = { 102 | event: 'Delivered Package', 103 | user_id: 'id', 104 | properties: { 105 | contents: 'Lug nuts', 106 | delivery_to: 'Robots of Chapek 9', 107 | foo_time: time, 108 | foo_date_time: dt, 109 | foo_date: date 110 | }, 111 | context: { 112 | crew: %w[Bender Fry Leela] 113 | }, 114 | integrations: { 115 | all: true 116 | }, 117 | timestamp: Time.new(2016, 3, 23), 118 | message_id: 'message-id' 119 | } 120 | expected_request_body = { 121 | 'event' => 'Delivered Package', 122 | 'userId' => 'id', 123 | 'anonymousId' => nil, 124 | 'properties' => { 125 | 'contents' => 'Lug nuts', 126 | 'delivery_to' => 'Robots of Chapek 9', 127 | 'foo_time' => '2018-03-11T10:20:00.000Z', 128 | 'foo_date_time' => dt.to_time.iso8601(3), 129 | 'foo_date' => '2018-03-12' 130 | }, 131 | 'context' => { 132 | 'crew' => %w[Bender Fry Leela], 133 | 'library' => { 134 | 'name' => 'simple_segment', 135 | 'version' => SimpleSegment::VERSION 136 | } 137 | }, 138 | 'integrations' => { 139 | 'all' => true 140 | }, 141 | 'timestamp' => Time.new(2016, 3, 23).iso8601(3), 142 | 'sentAt' => now.iso8601(3), 143 | 'messageId' => 'message-id' 144 | } 145 | request_stub = stub_request(:post, 'https://api.segment.io/v1/track') 146 | .with(body: expected_request_body, basic_auth: ['WRITE_KEY', '']) 147 | 148 | Timecop.freeze(now) do 149 | client.track(options) 150 | expect(request_stub).to have_been_requested.once 151 | end 152 | end 153 | 154 | context 'input checks' do 155 | before(:example) do 156 | stub_request(:post, 'https://api.segment.io/v1/track').with(basic_auth: ['WRITE_KEY', '']) 157 | end 158 | 159 | it 'errors without an event name' do 160 | expect { client.track(user_id: 'id') }.to raise_error(ArgumentError) 161 | end 162 | 163 | it 'errors with user_id and anonymous_id blank' do 164 | expect { client.track(event: 'test') }.to raise_error(ArgumentError) 165 | end 166 | 167 | it 'allows blank user_id if anonymous_id is present' do 168 | expect do 169 | client.track(event: 'test', anonymous_id: 'id') 170 | end.not_to raise_error 171 | end 172 | end 173 | end 174 | 175 | describe '#page' do 176 | it 'sends page info to segment' do 177 | time = Time.utc(2018, 3, 11, 10, 20) 178 | dt = DateTime.new(2018, 3, 11, 12, 20) 179 | date = Date.new(2018, 3, 12) 180 | 181 | options = { 182 | user_id: 'id', 183 | name: 'Zoidberg', 184 | properties: { 185 | url: 'https://en.wikipedia.org/wiki/Zoidberg', 186 | foo_time: time, 187 | foo_date_time: dt, 188 | foo_date: date 189 | }, 190 | context: { 191 | company: 'Planet Express' 192 | }, 193 | integrations: { 194 | all: true 195 | }, 196 | timestamp: Time.new(2016, 3, 23), 197 | message_id: 'message-id' 198 | } 199 | expected_request_body = { 200 | 'userId' => 'id', 201 | 'anonymousId' => nil, 202 | 'name' => 'Zoidberg', 203 | 'properties' => { 204 | 'url' => 'https://en.wikipedia.org/wiki/Zoidberg', 205 | 'foo_time' => '2018-03-11T10:20:00.000Z', 206 | 'foo_date_time' => dt.to_time.iso8601(3), 207 | 'foo_date' => '2018-03-12' 208 | }, 209 | 'context' => { 210 | 'company' => 'Planet Express', 211 | 'library' => { 212 | 'name' => 'simple_segment', 213 | 'version' => SimpleSegment::VERSION 214 | } 215 | }, 216 | 'integrations' => { 217 | 'all' => true 218 | }, 219 | 'timestamp' => Time.new(2016, 3, 23).iso8601(3), 220 | 'sentAt' => now.iso8601(3), 221 | 'messageId' => 'message-id' 222 | } 223 | request_stub = stub_request(:post, 'https://api.segment.io/v1/page') 224 | .with(body: expected_request_body, basic_auth: ['WRITE_KEY', '']) 225 | 226 | Timecop.freeze(now) do 227 | client.page(options) 228 | expect(request_stub).to have_been_requested.once 229 | end 230 | end 231 | 232 | context 'input checks' do 233 | before(:example) do 234 | stub_request(:post, 'https://api.segment.io/v1/page').with(basic_auth: ['WRITE_KEY', '']) 235 | end 236 | 237 | it 'errors with user_id and anonymous_id blank' do 238 | expect { client.page }.to raise_error(ArgumentError) 239 | end 240 | 241 | it 'allows blank user_id if anonymous_id is present' do 242 | expect do 243 | client.page(anonymous_id: 'id') 244 | end.not_to raise_error 245 | end 246 | end 247 | end 248 | 249 | describe '#group' do 250 | it 'sends group info to segment' do 251 | time = Time.utc(2018, 3, 11, 10, 20) 252 | dt = DateTime.new(2018, 3, 11, 12, 20) 253 | date = Date.new(2018, 3, 12) 254 | 255 | options = { 256 | user_id: 'id', 257 | group_id: 'group_id', 258 | traits: { 259 | name: 'Planet Express', 260 | foo_time: time, 261 | foo_date_time: dt, 262 | foo_date: date 263 | }, 264 | context: { 265 | locale: 'AL1' 266 | }, 267 | integrations: { 268 | all: true 269 | }, 270 | timestamp: Time.new(2016, 3, 23), 271 | message_id: 'message-id' 272 | } 273 | expected_request_body = { 274 | 'userId' => 'id', 275 | 'anonymousId' => nil, 276 | 'groupId' => 'group_id', 277 | 'traits' => { 278 | 'name' => 'Planet Express', 279 | 'foo_time' => '2018-03-11T10:20:00.000Z', 280 | 'foo_date_time' => dt.to_time.iso8601(3), 281 | 'foo_date' => '2018-03-12' 282 | }, 283 | 'context' => { 284 | 'locale' => 'AL1', 285 | 'library' => { 286 | 'name' => 'simple_segment', 287 | 'version' => SimpleSegment::VERSION 288 | } 289 | }, 290 | 'integrations' => { 291 | 'all' => true 292 | }, 293 | 'timestamp' => Time.new(2016, 3, 23).iso8601(3), 294 | 'sentAt' => now.iso8601(3), 295 | 'messageId' => 'message-id' 296 | } 297 | request_stub = stub_request(:post, 'https://api.segment.io/v1/group') 298 | .with(body: expected_request_body, basic_auth: ['WRITE_KEY', '']) 299 | 300 | Timecop.freeze(now) do 301 | client.group(options) 302 | expect(request_stub).to have_been_requested.once 303 | end 304 | end 305 | 306 | context 'input checks' do 307 | before(:example) do 308 | stub_request(:post, 'https://api.segment.io/v1/group').with(basic_auth: ['WRITE_KEY', '']) 309 | end 310 | 311 | it 'errors without a group id' do 312 | expect { client.group(user_id: 'id') }.to raise_error(ArgumentError) 313 | end 314 | 315 | it 'errors with user_id and anonymous_id blank' do 316 | expect { client.group(group_id: 'id') }.to raise_error(ArgumentError) 317 | end 318 | 319 | it 'allows blank user_id if anonymous_id is present' do 320 | expect do 321 | client.group(group_id: 'id', anonymous_id: 'id') 322 | end.not_to raise_error 323 | end 324 | end 325 | end 326 | 327 | describe '#alias' do 328 | it 'sends alias info to segment' do 329 | options = { 330 | user_id: 'id', 331 | previous_id: 'previous_id', 332 | context: { 333 | locale: 'AL1' 334 | }, 335 | integrations: { 336 | all: true 337 | }, 338 | timestamp: Time.new(2016, 3, 23), 339 | message_id: 'message-id' 340 | } 341 | expected_request_body = { 342 | 'userId' => 'id', 343 | 'anonymousId' => nil, 344 | 'previousId' => 'previous_id', 345 | 'context' => { 346 | 'locale' => 'AL1', 347 | 'library' => { 348 | 'name' => 'simple_segment', 349 | 'version' => SimpleSegment::VERSION 350 | } 351 | }, 352 | 'integrations' => { 353 | 'all' => true 354 | }, 355 | 'timestamp' => Time.new(2016, 3, 23).iso8601(3), 356 | 'sentAt' => now.iso8601(3), 357 | 'messageId' => 'message-id' 358 | } 359 | request_stub = stub_request(:post, 'https://api.segment.io/v1/alias') 360 | .with(body: expected_request_body, basic_auth: ['WRITE_KEY', '']) 361 | 362 | Timecop.freeze(now) do 363 | client.alias(options) 364 | expect(request_stub).to have_been_requested.once 365 | end 366 | end 367 | 368 | context 'input checks' do 369 | before(:example) do 370 | stub_request(:post, 'https://api.segment.io/v1/alias').with(basic_auth: ['WRITE_KEY', '']) 371 | end 372 | 373 | it 'errors without a previous id' do 374 | expect { client.alias(user_id: 'id') }.to raise_error(ArgumentError) 375 | end 376 | 377 | it 'errors with user_id and anonymous_id blank' do 378 | expect { client.alias(previous_id: 'id') }.to raise_error(ArgumentError) 379 | end 380 | 381 | it 'allows blank user_id if anonymous_id is present' do 382 | expect do 383 | client.alias(previous_id: 'id', anonymous_id: 'id') 384 | end.not_to raise_error 385 | end 386 | end 387 | end 388 | 389 | describe '#flush' do 390 | it 'does not blow up' do 391 | expect { client.flush }.not_to raise_error 392 | end 393 | end 394 | 395 | describe '#batch' do 396 | it 'batches events into a single request' do 397 | request_stub = stub_request(:post, 'https://api.segment.io/v1/batch') 398 | .with do |request| 399 | JSON.parse(request.body)['batch'].length == 2 400 | end 401 | client.batch do |analytics| 402 | analytics.identify(user_id: 'id') 403 | analytics.track(event: 'Delivered Package', user_id: 'id') 404 | end 405 | 406 | expect(request_stub).to have_been_requested.once 407 | end 408 | end 409 | end 410 | --------------------------------------------------------------------------------