Dynamic Stat Graphs in Rails

What started out as a 'quick feature' turned out to be a fun Ruby endeavor - below is a guide with some neat Ruby tricks to create dynamic stat graphs (sample above) with the help of Gruff library. In my visual database explorer article I talked about generating SVG graphs with the help of Scruffy. However, SVG is a nightmare when it comes to browser compatibility issues and inline support, hence in this instance we will settle for RMagick on the backend.

First off, if you haven't already, take a look at the Gruff RDoc - there is a lot of features hidden under the covers! Assuming you have the gem and the plugin installed, let's get right to it. First the code, then the explanations:

  def stats
    g = Gruff::Line.new('580x210')
    g.theme = {
       :colors => ['#ff6600', '#3bb000', '#1e90ff', '#efba00', '#0aaafd'],
       :marker_color => '#aaa',
       :background_colors => ['#eaeaea', '#fff']
     }

    g.hide_title = true
    g.font = File.expand_path('path/to/font.ttf', RAILS_ROOT)

    range = "created_at #{(12.months.ago.to_date..Date.today).to_s(:db)}"
    @users = User.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")
    @votes = Vote.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")
    @bookmarks = Bookmark.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")

    # Take the union of all keys & convert into a hash {1 => "month", 2 => "month2"...}
    # - This will be the x-axis.. representing the date range
    months = (@users.keys | @votes.keys | @bookmarks.keys).sort
    keys = Hash[*months.collect {|v| [months.index(v),v.to_s] }.flatten]

    # Plot the data - insert 0's for missing keys
    g.data("Users", keys.collect {|k,v| @users[v].nil? ? 0 : @users[v]})
    g.data("Votes", keys.collect {|k,v| @votes[v].nil? ? 0 : @votes[v]})
    g.data("Bookmarks", keys.collect {|k,v| @bookmarks[v].nil? ? 0 : @bookmarks[v]})

    g.labels = keys

    send_data(g.to_blob, :disposition => 'inline', :type => 'image/png', :filename => "site-stats.png")
  end

First few lines should be self-explanatory, we specify custom geometry, our theme colors, hide the title, and finally indicate the font we would like to use in the image. (Note: If RMagick complains about being unable to measure the font-sizes, make sure you have 'freetype-fonts' installed on your system) Next, is the SQL aggregation code, here we want to count the number of new users, votes and bookmarks on monthly basis:

range = "created_at #{(12.months.ago.to_date..Date.today).to_s(:db)}"
# .to_s(:db) is a helper to transform a date-range into SQL condition clause
#  ex. above: => "created_at BETWEEN '2006-01-05' AND '2007-01-05'"

@users = User.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")
# - count all users, whose created_at timestamp falls within the range specified above
# - group users by 'Year-Month', and count the number of occurences within each group
# ex. result:
#   count | date
#    ---------------
#     2   | 2005-10
#    24   | 2005-11
#    32   | 2005-12

There is one catch, if we have no new records in one of the intervals, than the count will be missing from the returned OrderedHash - we need to guard against this by injecting '0' counts for the missing intervals:

# first, we will take the union of the date arrays and sort our results
# ex: [1,2] | [2,3] => [1, 2, 3]
months = (@users.keys | @votes.keys | @bookmarks.keys).sort

# This is a tricky one. For the x-axis labels, Gruff requires a hash in the form of:
#   { 1 => "label", 2=> "label2" ... }
# Hence, we have a 'months' array, which we need to convert it into a hash.
#  1) * is the splat operator - it will split our array and pass each value separately to the block
#    - give it a try, in console do: p *[1,2,3]
#  2) Next, we will iterate over every value returned by the * operator and
#     replace it with a [index, value] pair
#    - ex: [a,b,c] => [[1,a],[2,b],[3,c]]
#  3) Now we flatten the resulting array and obtain:
#      [[1,a],[2,b],[3,c]].flatten => [1,a,2,b,3,c]
#  4) Result is passed to the hash constructor, which converts every tuple
#     into a key => value pair. Giving us:
#     Hash[[1,a,2,b,3,c]] => { 1 => 'a', 2 => 'b', 3 => 'c' }
#
#   Phew..
keys = Hash[*months.collect {|v| [months.index(v),v.to_s] }.flatten]

# Now we can iterate over all keys ans insert the user values for the months
# that have a count, and otherwise provide a '0'.
g.data("Users", keys.collect {|k,v| @users[v].nil? ? 0 : @users[v]})

Almost done. After assigning the x-axis labels we call send_data to stream the resulting PNG image directly to the browser. Now all we have to do is embed our image into a page, or call it directly:

<p align="center">
    <img src="<%= url_for :controller => "admin", :action=> "stats" %>" style="border:1px solid #aabcca;" />
</p>

After adding a quick CSS border and centering the image, you can feast your eyes on the latest trends of your paradigm-shifting application. For performance reasons, I would also recommend caching the action if you plan to display your results in a high-traffic area. For my personal use, I am only showing the trends in a private administration section (shown on the left). I can afford to regenerate the image on every refresh.

For more ideas and Ruby graphing tutorials take a look at: Visual Database Explorer and Dynamic Graphics in Rails 1.2.


Ilya Grigorik

Ilya Grigorik is a web performance engineer and developer advocate at Google, where his focus is on making the web fast and driving adoption of performance best practices at Google and beyond.