Activity Logs and Friend Feeds on Rails & pfeed

August 20, 2009

Friend networks and activity feeds are mainstay features of social media applications, and designing an implementation that won’t scar your code with the complexities of bidirectional logic and messaging queues is never easy.

That should be enough to send the thoughtful developer looking for a giant’s shoulders to stand on before climbing the task themselves, but in the case of Rails, otherwise known for the fecundity of its plugin community, there is no compelling solution stack to raise us up. That is, not until Abhishek Parolkar released pfeed:

A rails plugin that allows you to create extensible log of activity

I’m going to walk through the process of setting up pfeed in your application. The sample code I’ll use is drawn from my own implementation of the plugin as well as pfeed’s github pages, including:

Although I’ll be expanding on the code samples, you’ll still want probably want to consult the original documentation at some point.

Requirements

In my case, there were several requirements driving plugin selection, as well as the rationale behind even seeking a plugin in the first place instead of building the functionality from scratch.

  1. Should configure logging, not manually call a log method
  2. Should store additional arbitrary log data in easily retrievable way
  3. Should be able to log to/for any model, not just “users” (i.e. log a service, or report TO a service)
  4. Should be able to differentiate between log types/categories
  5. Should scope log activity to user or user groups
  6. Can easily be globally disabled
  7. Should work across model associations to log “nested” activity
  8. Won’t clash on models with many preexisting Active Record callbacks

I’m looking for a lot of flexibility here. And while I mention “logging” quite often, there’s nothing about “friendships” or “friend feeds”. That’s because the “logging” and “feed” system would be serving my application in a number of ways, and what I needed was something that could perform generic logging tasks which I could then arbitrarily interpret for various user and system activity tracking scenarios. I could have taken inspiration from somewhere like Insoshi but I wanted a solution that didn’t carry the furbelow of social networking.

Installation & Setup

Install the plugin from git…

script/plugin install git://github.com/parolkar/pfeed.git
rake pfeed:setup

Installation will, among other things, add two tables to your database and install the Inflectionist plugin, which is used to add linguistic sugar to the feed messages.

   create_table :pfeed_items do |t|
     t.string  :type
     t.integer :originator_id
     t.string :originator_type
     t.integer :participant_id
     t.string :participant_type
     t.text   :data
     t.datetime :expiry
     t.timestamps
   end

   create_table :pfeed_deliveries do |t|
     t.integer :pfeed_receiver_id
     t.string :pfeed_receiver_type
     t.integer :pfeed_item_id
     t.timestamps
   end

After that, you’re ready to configure your models. For this example we just need some users, friendships, and some models on which to track user activity.

NOTE The majority of the following model code is devoted to managing friendships, but it is not necessary to follow this design in your own friend models. pfeed will group logs for you on any Active Record association; there is nothing special about friends. The following user model could just as well be receiving a feed from comments (since it’s just another association).

User Model

class User < ActiveRecord::Base
  has_many :comments, :dependent => :destroy
  has_many :friendships, :dependent => :destroy
  has_many :friends,
    :through => :friendships,
    :foreign_key => 'friend_id',
    :class_name => 'User' do
    def active
      find(:all, :conditions => ['completed_at IS NOT NULL'])
    end
    def pending
      find(:all, :conditions => ['completed_at IS NULL'])
    end

  def request_friendship_with(friend_id)
    friend = self.friendships.detect {|f| f.friend_id.to_s == friend_id.to_s }

    if friend.blank?
       friend = Friendship.new(:user_id => self.id, :friend_id => friend_id)
       return friend.save
    else
      return false
    end
  end

  def complete_friendship_with(friend_id)
    friend = self.friendships.detect {|f| f.friend_id.to_s == friend_id.to_s && f.completed_at.blank? }
    unless friend.blank?
      friend.touch(:completed_at)
      friendship = Friendship.find_by_user_id_and_friend_id(friend_id, self.id)
      friendship.touch(:completed_at)
      return friendship.id
    end

    return false
  end
end

Friendship Model

class Friendship < ActiveRecord::Base
  belongs_to :user
  belongs_to :friend, :class_name => 'User'

  validates_presence_of :user_id, :friend_id
  validates_numericality_of :user_id, :friend_id

  after_create :ensure_complementary_record_exists
  after_destroy :ensure_complementary_record_is_destroyed

  named_scope :include_users, :include => [:user, :friend]

  protected
  def find_complementary_record
    Friendship.find_by_user_id_and_friend_id(friend_id, user_id)
  end

  private
  def ensure_complementary_record_exists
    Friendship.create!(:user => friend, :friend => user) if find_complementary_record.blank?
  end

  def ensure_complementary_record_is_destroyed
    if complement = find_complementary_record
      complement.destroy
    end
  end
end

Comment Model

class Comment < ActiveRecord::Base
    belongs_to :user
    belongs_to :parent, :polymorphic => true, :counter_cache => true
end

Tables

create_table :friendships, :force => true do |t|
   t.integer :user_id, :null => false
   t.integer :friend_id, :null => false
   t.datetime :completed_at

   t.timestamps
 end

create_table :users, :force => true do |t|
   t.string :name
   t.string :password
end

create_table :comments, :force => true do |t|
  t.integer :user_id, :null => false
  t.integer :parent_id, :null => false
  t.string :parent_type, :limit => 64, :null => false
  t.string :body, :limit => 2048, :null => false
end

pfeed Configuration

The concept behind pfeed is simple. There are models which receive feeds, and those which emit them, and in some cases a model may do both. Integrating pfeed revolves around (1) configuring your models, (2) deciding which methods you want to log, and (3) adding any additional information onto the default data which pfeed collects.

Feed Receivers

To make a model a feed receiver, you add the receives_pfeed macro call somewhere near the end of the class so as to avoid association collisions.

class User < ActiveRecord::Base
   . . .
   receives_pfeed
end

When you register a model as a receiver, it gains a pfeed_inbox association, so that you can call, say, @user.pfeed_inbox to return a collection of feed items. This is the primary way you’ll retrieve feed information – through the context of a receiver. In this example it’s a User model, but it really could be any sort of “listener” model under which you need to scope your logs.

Feed Emitters

Registering the models to emit feed requires a little more customization, and you do it with another macro call, emits_pfeeds, to which you pass a hash of methods to act as log triggers, and a hash of models who will receive feeds of those logs. It looks like this:

class Comment < ActiveRecord::Base
    . . .
    emits_pfeeds :on => [:create] , :for => [:all_users]
    def all_users
      User.all
    end
end

The :to argument

This hash will accept any method on your model. Sometimes you’ll want these to simply be native Active Record operations, like :create or :update_attribute, but sometimes you’ll want to use your model’s own methods, which we’ll do in a second when logging user friendships triggered by the example User model’s complete_friendship_with.

The :for argument

The :for hash accepts a method for defining the feed’s “audience”, where audience is some Active Record model or collection. The plugin has two native methods for this, :itself and :all_in_its_class. They are simply defined:

def itself
  self
end

def all_in_its_class
  self.class.find :all
end

If you look back you’ll see that I defined an all_users method for the Comment model. Defining recipients for the feeds in such a way provides a lot of power and flexibility in filtering how and when your feeds are broadcast.

With the preceding code samples, we’d be logging every comment that was created, and permitting those logs to be retrieved by users.

Logging Friendships

Say we want to log whenever a user’s friend becomes friends with another user, and then show it on a feed like this: travis became friends with parlokar about 6 minutes ago.

First, since our user will be both receiving and emitting feeds, we add both pfeed macro calls to the model.

class User < ActiveRecord::Base
   . . .
   has_many :friends
   . . .
   emits_pfeeds :on => [:complete_friendship_with], :for => [:itself ,:all_in_its_class, :friends]
   receives_pfeed
end

There is a lot of wonderful black magic happening here, but essentially this code translates to say that feeds triggered by the complete_friendship_with method will be associated with the “friending” user, and that when such users appear in the :friends association of another user, that user can see these feed items.

Between a model’s ability to generate feeds for actions itself takes (e.g. complete_friendship_with) as well as actions it takes against other models (e.g. Comment.create), pfeed provides an enormous amount of flexibility in structuring your log system.

Feed Item Messages

This would be an excellent time to refer to the pfeed documentation on custom feeds. Basically, pfeed will automatically log enough data to create a readable feed message that can be reconstructed from the user association in combination with the triggering method’s name. Feed items will generally look like this:

Travis updated attribute Email about 1 minute ago
Travis created comment about 10 minutes ago
Travis completed friendship with about 30 minutes ago

The last item should draw your attention since it looks quite incomplete without, say, the friend’s name. pfeed has a sort of templating system for determining the data saved by a log, and if you wish to expand upon the defaults, you’ll have to create a feed model for each of the actions for which you want more data.

First, create a subdirectory named “pfeeds” in your application’s models directory. Then, you’ll create models for each of the methods you need to capture custom data from, inheriting from the PfeedItem model.

The directory structure will look like this:

– models
–– pfeeds
–––– comment_created.rb
–––– user_completed_friendship_with.rb

And the feed models will look like this:

class Pfeeds::UserCompletedFriendshipWith < PfeedItem
def  pack_data(method_name, method_name_in_past_tense, returned_result, *args_supplied_to_method, &block_supplied_to_method)
     self.data = {} if ! self.data

     friendship = Friendship.include_users.find(returned_result)

     hash_to_be_merged = {:friend => friendship.friend.name }
     self.data.merge!  hash_to_be_merged
     super
  end
end

In this example I want to capture the name of the friend with whom the logged user completed a friendship, so I define a field (”:friend” in self.data arguments) which will then be serialized into the data feed.

When you’re working with a feed item, you can then retrieve the custom data like so:

friend_name = @feed_item.data[:friend]

Summary

Now that we’ve covered each of the four corners of pfeed, so to speak, we can summarize integration as follows:

  1. Define feed receivers with receives_pfeed
  2. Define feed emitters with emits_pfeeds
  3. Define emits_pfeeds :for methods to scope logs
  4. Create PfeedItem models for capturing custom log data

I started this post with a list of requirements, and the various hooks pfeed gives you provide answers to all of them. The extent to which you configure and customize your logging system is up to you, and for me this was the real power of the plugin. Pfeed goes to the exact boundary of common logging functionality (the code I want to avoid writing myself if possible), and yet stops precisely before incurring design commitments that might clash with my application, leaving the right amount of “freedom with help” that is the hallmark of a valuable plugin.

So what are you waiting for? Go git it. . .

Tags: , , ,

1 Response

  1. 10x for the code! cheers!

Leave a Reply

Powered by WP Hashcash