Skip to content

Instantly share code, notes, and snippets.

@eskfung
Last active April 30, 2023 08:29
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save eskfung/d2dc00c8a987cbb5beb3 to your computer and use it in GitHub Desktop.
Save eskfung/d2dc00c8a987cbb5beb3 to your computer and use it in GitHub Desktop.
Notify KISSmetrics of Salesforce actions

I combine a simple web server (which I'll call the "Forwarding Webserver") hosted on Heroku with Salesforce Apex to have KISSmetrics track certain actions done in Salesforce. Currently, we're tracking when a lead is created and converted, and when an opportunity is won or lost.

The workflow looks like this:

  • When a Lead is created...
  • ...an Apex trigger runs...
  • ...which makes a web callout to your web server, with information about the Lead(s) that were just created.
  • The forwarding web server then receives information about the Lead(s)...
  • ...and for each Lead...
  • ...the web server uses the KISSmetrics Tracking API to track that the lead was created.

The Forwarding Webserver

Today, this is a Sinatra web server hosted on Heroku.

  1. Set up the route (lib/routes/salesforce.routes.rb)
  2. Set up the client (lib/clients/sfdc.tracking.client.rb)
  3. Set up the models (lib/models/sfdc.event.rb, lib/models/sfdc.sobject.rb)
  4. Deploy!
  5. You will need the URL of the route for the next step.

Within Salesforce

I have access to the Salesforce sandbox, which is required in order to add Apex classes.

  1. Add apex classes, test factory, and tests.
  • Setup -> Develop -> Apex Classes
  • KMTracking.cls
  • KMTrackingLeadTest.cls
  • KMTrackingOpportunityTest.cls
  • TestFactory.cls
  1. Add triggers.
  • Setup -> Customize -> Opportunities -> Triggers
    • KMTrackOpportunityClose.trigger
  • Setup -> Customize -> Leads -> Triggers
    • KMTrackLeadCreation.trigger
    • KMTrackLeadConversion.trigger
  1. Add the URL of the forwarding webserver to Setup -> Security Controls -> Remote Site Settings.

  2. When ready, add all of the above to an Outbound Change Set and deploy from sandbox to production.

public class KMTracking {
@future(callout=true)
public static void sendRequest(String json_payload, String event_name) {
// This is the URL of your forwarding server
String trackingEndpoint = 'http://requestb.in/abcdef';
// This is a randomly-generated string to authenticate Salesforce to your forwarding server
String webhookSecret = 'foobarbaz';
// Make a single request to your forwarding server. The request body contains the SObjects to track.
HttpRequest req = new HttpRequest();
req.setMethod('POST');
req.setBody(json_payload);
req.setHeader('X-KM-Event-Name', event_name);
req.setEndpoint(trackingEndpoint);
req.setHeader('Authorization', 'Basic ' + webhookSecret);
Http h = new Http();
if (!Test.isRunningTest()) {
h.send(req);
}
}
public static void trackLeadsCreated(List<String> leads) {
// When tracking leads being created, get the Lead Id, Email, and CreatedDate fields
List<Lead> leadsToTrack = [SELECT Id, Email, CreatedDate FROM Lead WHERE Id in :leads];
if (leadsToTrack.size() > 0) {
sendRequest(JSON.serialize(leadsToTrack), 'Salesforce Lead Created');
}
}
public static void trackLeadsConverted(List<String> leads) {
// When tracking leads being converted, get the Lead Id, Email, and ConvertedDate fields
List<Lead> leadsToTrack = [SELECT Id, Email, ConvertedDate FROM Lead WHERE Id in :leads];
if (leadsToTrack.size() > 0) {
sendRequest(JSON.serialize(leadsToTrack), 'Salesforce Lead Converted');
}
}
public static void trackOpportunitiesWon(List<String> opptys) {
// When tracking won opportunities, get the Opportunity Id, Amount, and Email__c fields
// REALIZE THAT EMAIL__C IS A CUSTOM OPPORTUNITY FIELD
List<Opportunity> opptysToTrack = [SELECT Id, Amount, Email__c FROM Opportunity WHERE Id in :opptys];
if (opptysToTrack.size() > 0) {
sendRequest(JSON.serialize(opptysToTrack), 'Salesforce Opportunity Closed (Won)');
}
}
public static void trackOpportunitiesLost(List<String> opptys) {
// When tracking lost opportunities, get the Opportunity Id, Amount, and Email__c fields
// REALIZE THAT EMAIL__C IS A CUSTOM OPPORTUNITY FIELD
List<Opportunity> opptysToTrack = [SELECT Id, Amount, Email__c FROM Opportunity WHERE Id in :opptys];
if (opptysToTrack.size() > 0) {
sendRequest(JSON.serialize(opptysToTrack), 'Salesforce Opportunity Closed (Lost)');
}
}
}
@isTest
public class KMTrackingLeadTest {
@isTest static void createOneLead(){
Lead lead = TestFactory.createLead();
System.assertEquals(lead.IsDeleted, false);
}
@isTest static void convertOneLead(){
Lead lead = TestFactory.createLead();
List<Lead> leadsToConvert = new List<Lead>();
leadsToConvert.add(lead);
Database.LeadConvert lc = new Database.LeadConvert();
lc.setLeadId(lead.id);
LeadStatus convertStatus = [SELECT Id, MasterLabel FROM LeadStatus WHERE IsConverted=true LIMIT 1];
lc.setConvertedStatus(convertStatus.MasterLabel);
Database.LeadConvertResult lcr = Database.convertLead(lc);
System.assert(lcr.isSuccess());
}
}
@isTest
public class KMTrackingOpportunityTest {
@isTest static void loseOneOpportunity(){
Opportunity o = new Opportunity(
Name = 'Test Opportunity',
StageName = '1-Discovery',
CloseDate = Date.today(),
Amount = 9000.00
);
insert o;
o.StageName = '7-Closed Lost';
o.Lost_Reason__c = 'Price/Value';
update o;
Opportunity closed_o = [SELECT Id, IsClosed, IsWon FROM Opportunity WHERE Id=:o.id][0];
System.assertEquals(closed_o.IsClosed, true);
System.assertEquals(closed_o.IsWon, false);
}
}
trigger KMTrackLeadConversion on Lead (after update) {
List<String> lead_ids = new List<String>();
for(Lead lead : Trigger.new) {
if(lead.IsConverted && !trigger.oldMap.get(lead.Id).IsConverted) {
lead_ids.add(lead.Id);
}
}
KMTracking.trackLeadsConverted(lead_ids);
}
trigger KMTrackLeadCreation on Lead (after insert) {
List<String> lead_ids = new List<String>();
for(Lead lead : Trigger.new) {
lead_ids.add(lead.Id);
}
KMTracking.trackLeadsCreated(lead_ids);
}
trigger KMTrackOpportunityClose on Opportunity (after update) {
List<String> won_ids = new List<String>();
List<String> lost_ids = new List<String>();
for(Opportunity o : Trigger.new) {
if(o.IsClosed && !trigger.oldMap.get(o.Id).IsClosed) {
if(o.IsWon) {
won_ids.add(o.Id);
} else {
lost_ids.add(o.Id);
}
}
}
KMTracking.trackOpportunitiesWon(won_ids);
KMTracking.trackOpportunitiesLost(lost_ids);
}
require 'clients/sfdc.tracking.client'
# A route in a sinatra web server
post '/salesforce.track' do
salesforce = SFDC::KMTracking::Client.new
# Check for basic authentication
if salesforce.valid_signature?(env['HTTP_AUTHORIZATION'])
salesforce.parse_and_record_events(request.body.read, env['HTTP_X_KM_EVENT_NAME'])
status(200)
body("Acknowledged")
else
puts "POST /salesforce.track: Signature invalid."
status(401)
body("Basic Authentication Failed")
end
end
require 'models/sfdc.sobject'
require 'httparty'
require 'uri'
module SFDC
module KMTracking
class Event
include HTTParty
base_uri ENV["KM_TRACKING_ENDPOINT"] # https://trk.kissmetrics.com
def initialize(sobject, event_name = "")
@apikey = ENV["KM_API_KEY"] # Your KISSmetrics API key
@sobject = sobject
@event_name = event_name
end
# POSTs the KISSmetrics account-based endpoint with data representing this existing event.
# @note Makes a network request!
#
# @return nil
def record
if can_record_event?
params = URI.encode_www_form( {
:_n => km_event_name,
:_p => km_identity,
:_k => @apikey
}.merge(km_properties) )
puts "Posting to #{self.class.base_uri}/e with parameters: #{params}"
self.class.get("/e", :query => params)
end
end
# Returns whether there is enough info in this Event instance to record a KISSmetrics event.
#
# @return [Boolean]
def can_record_event?
!!(km_identity && km_event_name)
end
# Generates the name of the KISSmetrics event.
#
# @return [String]
def km_event_name
@event_name
end
# Generates the KISSmetrics identity.
#
# @return [String]
def km_identity
@sobject.email || @sobject.email__c
end
# Generates a hash of the KISSmetrics properties.
#
# @return [Hash]
def km_properties
km_props = {}
km_props["Salesforce URL"] = @sobject.url
if @sobject.opportunity?
if km_event_name == "Salesforce Opportunity Closed (Won)"
km_props["Opportunity Amount Won"] = @sobject.amount
elsif km_event_name == "Salesforce Opportunity Closed (Lost)"
km_props["Opportunity Amount Lost"] = @sobject.amount
end
km_props["Opportunity Amount"] = @sobject.amount
end
km_props
end
end
class Events < Array
# Creates an array of Event objects
def initialize(sobjects = [], event_name)
sobjects.each {|sobject_hash|
self << SFDC::KMTracking::Event.new(
SFDC::KMTracking::SObject.new(sobject_hash),
event_name
)
}
end
# Calls `record` on each Event object in self.
def record_all
self.each {|event|
event.record
}
end
end
end
end
module SFDC
module KMTracking
class SObject
def initialize(sobject_hash = {})
@sobject_hash = sobject_hash
end
def attributes
@sobject_hash.fetch("attributes", {})
end
def type
attributes.fetch("type", nil)
end
def url
attributes.fetch("url", nil)
end
def lead?
type == "Lead"
end
def opportunity?
type == "Opportunity"
end
def id
@sobject_hash.fetch("Id", nil)
end
def email
@sobject_hash.fetch("Email", nil)
end
def email__c
@sobject_hash.fetch("Email__c", nil)
end
def created_date
@sobject_hash.fetch("CreatedDate", nil)
end
def converted_date
@sobject_hash.fetch("ConvertedDate", nil)
end
def amount
@sobject_hash.fetch("Amount", nil)
end
end
end
end
require 'models/sfdc.event'
module SFDC
module KMTracking
class Client
def initialize
# This is the same string as the webhookSecret defined in KMTracking.cls
@secret = ENV['SFDC_TRACKING_SECRET']
end
# Records a KM event for each Salesforce SObject contained in the body.
#
# @param body [string] a POST string representing an array of Salesforce SObjects
# @param event_name [string] a string describing the KM event to record
# @return nil
def parse_and_record_events(body, event_name)
events = SFDC::KMTracking::Events.new( JSON.parse(body), event_name )
events.record_all()
end
# Checks for basic HTTP authentication with the Authorization header
#
# @param header_signature [String] the Authorization header from Salesforce's postback
# @return [Boolean] whether the signature matches the generated one
def valid_signature?(header_signature)
return !header_signature.nil? && header_signature.strip == signature.strip
end
private
def signature
"Basic #{@secret.strip}"
end
end
end
end
@isTest
public class TestFactory {
public static Lead createLead() {
Lead lead = new Lead(
LastName = 'Testman',
Company = 'Testco',
Email = 'tester@test.co',
Business_Type__c = 'Unknown At This Time',
Status = 'New'
);
insert lead;
return lead;
}
public static Opportunity createOpportunity(Account account) {
Opportunity oppty = new Opportunity(
Name = 'Test Opportunity',
AccountId = account.id,
StageName = '1-Discovery',
CloseDate = Date.today().addDays(30),
Type = 'New Bus',
Amount = 1200
);
insert oppty;
return oppty;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment