Tracking Pixels in ruby

The simplest way to do this is via a tracking pixel – a small, invisible image that is loaded from your server every time your medium is opened.

by: t0nylombardi on

If you are sending out content to your customers, it pretty handy to know if the content is loading. I speak if you are sending out emails, newsletters, ads, etc. If you have ever sent out an email newsletter from a service like Mailchimp, you would have seen email open graphs. Of course, tracking this stuff is super important for a campaign, but it would also be interesting to see if users are seeing your content.

The simplest way to do this is via a tracking pixel – a small, invisible image that is loaded from your server every time your medium is opened. This is fairly simple to achieve using Rails by building a simple Rack application.

The idea

Picture you are creating ads for clients and you are sending them out to ad shops. If you are using a program like Google Doubleclick. Google DoubleClick allows you to track your ad through a pixel tracking url. This data can be gathered to figure out trends when those ads, emails, or newletters are being opened.

The Setup

We’ll add one model: One to tracking pixels:

rails g model Pixel ip_address, campaign, content_type, created_at:date ,city, state, user_agent, referral, banner_size

All of these fields are strings except for created_at so by default they are created as strings.

the general idea we have here is to get attach a url into an email or something to see if its being loaded such as:

yourUrl.com/assets/tracker.gif?campaign=SomeCampaign&banner_size=300x250&content_type=Email

Right, now the magic bit:

Create a directory called /lib/tracker and create a new file called rack.rb

# app/lib/tracker/rack.rb

module Tracker
  class Rack

    def initialize(app)
      @app = app
    end

    def call(env)
      @req = ::Rack::Request.new(env)
      if @req.path_info =~ /tracker.gif/
        result = Services::Params.deploy @req.query_string
        location = Services::Locations.lookup(@req.ip)
        ip_address = location["ip"] || @req.ip
        params = {
          ip_address:     ip_address,
          campaign:       result[:campaign],
          banner_size:    result[:banner_size],
          content_type:   result[:content_type],
          city:           location["city"],
          state:          location["region_name"],
          user_agent:     @req.user_agent,
          referral:       @req.referer
        }

        if @pixels = Pixel.create!(params)
          [
            200, { 'Content-Type' => 'image/gif' },
            [File.read(File.join(File.dirname(__FILE__), 'tracker.gif'))]
          ]
        else
          Rails.logger.warn "\n\n Failed to create record on:#{Date.today}"
        end
      else
        @app.call(env)
      end
    end

  end
end

Within the method call, several things happen. First is that it will listen to the incomming request for the pixel which we will get into that later. Second we will call on the module Services. The services module does nost if tge heavy lifting for us. In the in lib/servies/ folder will be two files.

What we need to essentially do is to get the url params, brake them up and return a hash of values that can be entered into the database.

Params:
# lib/services/params.rb

module Services
  class Params
    require 'active_support/all'

    def self.deploy(obj)
      @url_obj = obj
      if obj.class == ActionController::Parameters
        return_new_hash @url_obj
      else
        check_for_decode
      end
    end

    def self.check_for_decode
      if is_base_64 @url_obj
        str = decode(@url_obj)
        return_params(str)
      else
        return_params @url_obj
      end
    end

    def self.return_new_hash(hash)
      hash[:campaign] = hash.delete(:track)
      campaign = hash[:campaign].split(/=/)
      hash[:campaign] = campaign[1]
      hash
    end

    def self.return_params(str)
      arry  = str.split(/&/)
      hash = {}
      if arry.length <= 1
        raise ActionController::RoutingError.new('Not Found')
      else
        arry.each{|a| hash[a.scan(/^\w*/).join('').to_sym] = a.gsub(/^(\w*=)/,'')}
      end
      hash
    end

    def self.decode(str)
      str = Base64.urlsafe_decode64(str)
    end

    def self.is_base_64(str)
      str =~ /^([A-Za-z0-9+\/]{4})*([A-Za-z0-9+\/]{4}|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{2}==)$/
    end

  end
end

this class also works for encoding whole params into a single 64base string(don’t ask why. it was a good idea at the time)

The other part of the module handles location. This calls from a ip location service http://freegeoip.net.

Locations:
# lib/services/locations.rb

module Services
  class Locations
    require 'net/http'

    def self.lookup(ip)
      ip_address = ip
      unless Rails.env.production?
        ip_address = random_ip_address
      end
      url = "http://freegeoip.net/json/#{ip_address}"
      begin
        HTTParty.get(url, timeout: 2)
      rescue Timeout::Error
        Rails.logger.warn("Could not post to #{url}: timeout")
        {city: nil, region_name: nil}
      rescue
        Rails.logger.warn("Could not post to #{url}")
        {city: nil, region_name: nil}
      end
    end

    def self.check_for_local(ip)
      if ["127.0.0.1", "::1"].include? ip
        "108.41.23.150"
      else
        ip
      end
    end

    # These are fake IPS. Put real physical IPS when
    # testing in developement enviorment.
    def self.random_ip_address
      %w{ 127.19.209.10
          127.21.23.150
          127.31.23.155
          127.41.23.170
          127.59.209.14
          127.69.209.80 }.sample
    end

  end
end

Last thing we need to do is to make a 1x1 opaque pixel and call it tracker.gif and put it app/assets/images && also in lib/tracker.

You can also add some validations to your model

class Pixel < ActiveRecord::Base
  validates_presence_of :ip_address
end

So there you have it. The heart of the program lies within your lib folder and calls on Rack to listen for that pixel. I am sure there is more we can add to this idea. There is no front end to this, you will have to take the numbers off your database.

Happy Coding =)

If you liked this article, please consider buying me a coffee

Cheers!