Custom field history tracker in Salesforce

Telegram logo Join our Telegram Channel

Hello Trailblazer! There is a limit on how many fields you can track using the standard Salesforce Field History Tracking which is why we often need to write our own logic for custom field history tracking!

In this post, I will share with you how can reuse the simple and reusable custom field history tracking for any Salesforce Object.


Use Case

Need to create a custom field history tracking system so that we can track changes for more than 20 fields in Salesforce. Need to build a declarative tool so that admins can easily add/remove fields and objects in the field tracking system.


The Approach

  • We will create a generic object to store the field history data like Field Name, Old Value, New Value, Record Id, and a User who changed the data. Here is the complete schema for Field history.

    Custom Field History Object Schema

  • Also, we need two additional Long Text fields so that we can track Long text fields as well.
  • We need to create two custom metadata to store the Object and field information so that we can track data based on that.
  • Field Tracker Object - Define the object name to track fields from.

    Field Tracker Object

  • Field Track Field - Define fields to be tracked.

    Field Track Field

  • Lastly, we need to write a generic class (FieldTrackerService) with a function that will take the name of the Object, and records data. Based on the provided information it will create the history records.
  • Also, each configuration can be turned on/off from the Object as well as the Field level so that you can turn off field tracking by just checking/unchecking the Is Active flag.
  • Lastly, we need to call the generic class function from the Object triggers.

Implementation

Download the source code from this GitHub repo: custom-field-tracker-salesforce.

Or

Install the unmanaged package from here: Custom Field Tracker Unmanaged Package.


Example Trigger Code

Please make sure that you add Object and field values in the custom metadata before testing this! For testing purposes, I have it for the Account object and BillingCountry field.

AccountTrigger.trigger

trigger AccountTrigger on Account(after insert, after update) {
	AccountTriggerHandler.saveFieldHistories(Trigger.new, Trigger.oldMap);
}

AccountTriggerHandler.cls

public with sharing class AccountTriggerHandler {
	public static void saveFieldHistories(
		List<Account> newAccounts,
		Map<Id, Account> oldMap
	) {
		FieldTrackerService fts = FieldTrackerService.getInstance('Account');
		fts.saveFieldHistories(newAccounts, oldMap);
	}
} 

AccountTriggerTest.cls

@isTest
class AccountTriggerTest {
	@TestSetup
	static void makeData() {
		List<Account> accounts = new List<Account>();
		for (Integer i = 0; i < 200; i++) {
			Account acc = new Account(
				Name = 'Account FTS ' + i,
				BillingCountry = 'United States'
			);
			accounts.add(acc);
		}

		insert accounts;
	}

	@IsTest
	static void testAfterInsertHistory() {
		Set<Id> accountIds = new Map<Id, Account>(
				[SELECT Id FROM Account WHERE Name LIKE 'Account FTS %']
			)
			.keySet();

		Test.startTest();
		System.assertEquals(
			[
					SELECT Id
					FROM Field_History__c
					WHERE
						Tracked_Field_API__c = 'BillingCountry'
						AND Tracked_Record_Id__c IN :accountIds
						AND Old_Value__c = NULL
				]
				?.size(),
			200,
			'Field history records not found'
		);
		Test.stopTest();
	}

	@IsTest
	static void testAfterUpdateHistory() {
		Account[] accounts = [
			SELECT Id
			FROM Account
			WHERE Name LIKE 'Account FTS %'
		];

		Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();

		for (Account acc : accounts) {
			acc.BillingCountry = 'India';
		}

		update accounts;

		Test.startTest();
		System.assertEquals(
			[
					SELECT Id
					FROM Field_History__c
					WHERE
						Tracked_Field_API__c = 'BillingCountry'
						AND Tracked_Record_Id__c IN :accountIds
						AND Old_Value__c = 'United States'
				]
				?.size(),
			200,
			'Field history records not found'
		);
		Test.stopTest();
	}
}


Further Customizations

Make sure that you make the Field History Object read-only to all users so that they can't edit the data from history.

You can add additional features as you need.

I have planned to add a Lightning web component to show the related history records on standard record pages.

Please let me know if you wish to add new features. You can also contribute to this code by submitting a pull request to this repository - custom-field-tracker-salesforce.

I hope that was helpful! Thanks for reading!


13 comments:
  1. Hi Rahul, will it work for custom object? I have tried for custom object but i'm getting null pointer exception in apex around this line i.e, ftObject = Field_Tracker_Object__mdt.getInstance(objectName). Its works only for account not for other objects. Also i called handler etc for custom object as well.

    ReplyDelete
  2. Hello TechGang it will work for custom objects too. Check if you have correct data in custom mdt.

    ReplyDelete
  3. Hi, is there a way to use @future methods here? I am a new developer, and I have this legacy code that is not very optimised. So, using a @future method pretty much ensures that I don't run into Salesforce government limits.

    ReplyDelete
    Replies
    1. Yes you can use future method but you will have to pass the data in primitive format like map or lists, but it isn't worth doing that, I would rather suggest to optimise your old code if possible.

      Delete
    2. Yup, that's what I fear, it would take time for either solutions to be developed.

      Delete
    3. How would I attach this object as related list to the record page layout (ex. Case)?

      Delete
    4. Create a simple custom lwc component to show the list with some pagination. Put a condition to match the record id

      Delete
  4. Your picture for Field Tracker Object is the same as Field Tracker Field. Is that supposed to be like that?

    ReplyDelete
    Replies
    1. Thank you Raop for spotting that. i think that is by mistake I will correct it.

      Delete
  5. Just want to point out that if you have an enabled workflow rule on your object, you'll need to add a static boolean to prevent this from running more than once. public static boolean firstRun = true; in your AccountTriggerHandler and inside your trigger, do the following: if (AccountTriggerHandler.firstRun) {
    AccountTriggerHandler.firstRun = false;
    AccountTriggerHandler.saveFieldHistories(Trigger.new, triggerOldMap);
    }

    Hope this helps someone.

    ReplyDelete
  6. Hi Rahul,
    i am trying this for a custom object would you mind putting a screen for the custom metadata entries.. I am getting null pointer exception...

    ReplyDelete

Hi there, comments on this site are moderated, you might need to wait until your comment is published. Spam and promotions will be deleted. Sorry for the inconvenience but we have moderated the comments for the safety of this website users. If you have any concern, or if you are not able to comment for some reason, email us at rahul@forcetrails.com