Custom Field History Tracking

Overcoming Custom Field History Tracking Limits in Salesforce: Tips to Track More Fields

By Anandhakumar Thangasamy
Senior Salesforce Developer

Custom Field History Tracking 

As you know Salesforce field history tracking is the wonderful standard feature to store the historical data for an audit purpose. Historical data is always powerful when it comes to data analytics. But there is a limitation in this feature, we can track up to a maximum of 20 fields per object only. What if your organization needs to track more than that? Here is the solution. 

Below are the steps to setup custom history tracking, 

  • Create a custom metadata to store the configuration like Object API Name, List of Fields which are going to be tracked.
  • Create a custom object to store old and new values of the data(history records).
  • Create an apex class to create a custom history record.
  • Call that apex method from the trigger wherever you want to track the history. 

Create a Custom Metadata 

  • Go to Setup > Custom Metadata Type > New.
  • Create a custom metadata type with the following details,
  • Label: Custom History Tracking
  • Plural Label: Custom History Tracking
  • Object Name: Custom_History_Tracking
  • Click Save.
  • Once custom metadata is created, create a new custom field on that object with the following details.
  • Data type: Text Area (Long)
  • Field Label: Fields
  • Field Name: Fields
  • Create a metadata record for the objects which you want to track the fields. For example, I want to track Account object fields, click Manage Custom History Tracking's and click New.
  • Enter the following details,
  • Label: API name of your object i.e. Account
  • Custom History Name will be auto-populated.
  • Fields: Enter the fields list as per the following format. Name; Phone; Email 

Create a Custom Object 

  • Go to Setup > Object Manager > New Custom Object.
  • Create a new object with the following details,
  • Label: Custom History
  • Plural Label: Custom Histories
  • Object Name: Custom_History
  • Set a name field as auto-number with whatever format you want.
  • Click Save.
  • Create the following fields, 

Field Label 

API Name 

Data Type 

Record Id 

Record_Id 

Text(18) 

Field Changed 

Field_Changed

Text(100) 

Related To 

Related_To 

Text(100) 

Old Value 

Old_Value 

Text(255) 

New value 

New_Value 

Text(255) 

 

Create an Apex Class 

Create the below Apex class 

public with sharing class CustomHistoryTracking { 
    public static void trackHistory(String strRelatedTo, map mapNewRecords, map mapOldRecords) { 
        try { 
            SObjectType objType = mapNewRecords.values()[0].getSObjectType(); 
            String strObjectName = String.valueOf(objType); 
            if (strObjectName.contains('__c')) { 
                strObjectName = strObjectName.left(strObjectName.length() - 3); 
            } 

            // 1. Read Custom History Tracking custom meta data to get the list of fields to track 
            list lstFieldsToTrack = new List(); 
            for (Custom_History_Tracking__mdt customHistoryTracking : [SELECT Fields__c FROM Custom_History_Tracking__mdt WHERE DeveloperName = :strObjectName]) { 
                lstFieldsToTrack.addAll(customHistoryTracking.Fields__c.split('; ')); 
                System.debug('lstFieldsToTrack: ' + lstFieldsToTrack); 
            } 

            if (!lstFieldsToTrack.isEmpty()) { 
                // 2. Retrieve the field describe result to know the data type 
                Schema.DescribeFieldResult fieldDescribeResult; 
                DescribeSObjectResult objectDescribeResult = objType.getDescribe(); 
                map mapFieldDescribes = new Map(); 

                for (String strFieldAPIName : lstFieldsToTrack) { 
                    // Field API Name for Lookup field is the format eg: Social_Reputation_Manager__c:ACC_Social_Reputation_Manager_r_Name 
                    // The first part before : is the actual Lookup field being tracked. 
                    if (strFieldAPIName.contains(':')) { 
                        strFieldAPIName = strFieldAPIName.substringBefore(':'); 
                    }
                    mapFieldDescribes.put(strFieldAPIName, objectDescribeResult.fields.getMap().get(strFieldAPIName).getDescribe()); 
                } 

                // 3. Track history if the field values are changed 
                list lstHistoryRecords = new List(); 
                Map> mapRecIdRelNames = new Map>(); 
                Map> mapRelNameLookupIds = new Map>(); 
                for (sObject newRecord : mapNewRecords.values()) { 
                    sObject oldRecord = mapOldRecords.get(newRecord.Id); 
                    for (String strFieldAPIName : lstFieldsToTrack) { 
                        // Extract the API Field Name in case of Lookup fields. 
                        String strRefFieldAPIName; 
                        if (strFieldAPIName.contains(':')) { 
                            strRefFieldAPIName = strFieldAPIName; 
                            strFieldAPIName = strFieldAPIName.substringBefore(':'); 
                        } 
                        if (newRecord.get(strFieldAPIName) != oldRecord.get(strFieldAPIName)) { 
                            fieldDescribeResult = mapFieldDescribes.get(strFieldAPIName); 
                            // In case of Lookup fields collect the old and new Look up Field ID's. 
                            if (String.isNotBlank(strRefFieldAPIName) && fieldDescribeResult.getType() == Schema.DisplayType.Reference) { 
                                String strRelationshipName = strRefFieldAPIName.substringAfter(':'); 
                                if (oldRecord.get(strFieldAPIName) != null) { 
                                    if (!mapRelNameLookupIds.containsKey(strRelationshipName)) { 
                                        mapRelNameLookupIds.put(strRelationshipName, new List()); 
                                    } 
                                    mapRelNameLookupIds.get(strRelationshipName).add((Id) oldRecord.get(strFieldAPIName)); 
                                } 
                                if (newRecord.get(strFieldAPIName) != null) { 
                                    if (!mapRelNameLookupIds.containsKey(strRelationshipName)) { 
                                        mapRelNameLookupIds.put(strRelationshipName, new List()); 
                                    } 
                                    mapRelNameLookupIds.get(strRelationshipName).add((Id) newRecord.get(strFieldAPIName)); 
                                } 
                                if (!mapRecIdRelNames.containsKey(newRecord.Id)) { 
                                    mapRecIdRelNames.put(newRecord.Id, new List()); 
                                } 
                                mapRecIdRelNames.get(newRecord.Id).add(strRefFieldAPIName); 
                            } else { 
                                Custom_History__c customHistory = new Custom_History__c(); 
                                customHistory.Record_Id__c = newRecord.Id; 
                                customHistory.Field_Changed__c = fieldDescribeResult.getLabel(); 
                                customHistory.Related_To__c = strRelatedTo; 
                                if (fieldDescribeResult.getType() == Schema.DisplayType.TextArea && fieldDescribeResult.getLength() > 255) { 
                                    // If it is long text area or rich text area, then do not track what exactly changed 
                                    customHistory.New_Value__c = 'This field value changed'; 
                                } else if (fieldDescribeResult.getType() == Schema.DisplayType.Date || fieldDescribeResult.getType() == Schema.DisplayType.DateTime) { 
                                    customHistory.Old_Value__c = (oldRecord.get(strFieldAPIName) != null ? String.valueOf(oldRecord.get(strFieldAPIName)) + ' GMT' : ''); 
                                    customHistory.New_Value__c = (newRecord.get(strFieldAPIName) != null ? String.valueOf(newRecord.get(strFieldAPIName)) + ' GMT' : ''); 
                                } else { 
                                    customHistory.Old_Value__c = String.valueOf(oldRecord.get(strFieldAPIName)); 
                                    customHistory.New_Value__c = String.valueOf(newRecord.get(strFieldAPIName)); 
                                } 
                                lstHistoryRecords.add(customHistory); 
                            } 
                        } 
                    } 
                } 

                // Insert Custom History for Lookup Fields here. 
                if (!mapRecIdRelNames.isEmpty()) { 
                    // Retrieiving the list of Queries for the Lookup field 
                    Map mapRelNameQuery = new Map(); 
                    for (Custom_History_Tracking__mdt customHistoryTracking : [ 
                        SELECT Fields__c, DeveloperName 
                        FROM Custom_History_Tracking__mdt 
                        WHERE DeveloperName IN :mapRelNameLookupIds.keyset() 
                    ]) { 
                        mapRelNameQuery.put(customHistoryTracking.DeveloperName, customHistoryTracking.Fields__c); 
                    } 

                   if (!mapRelNameQuery.isEmpty()) { 
                        Map mapRelNameWhereIds = new Map(); 
                        for (String strRelName : mapRelNameQuery.keyset()) { 
                            String strRefIds = '('; 
                            for (Id objRefId : mapRelNameLookupIds.get(strRelName)) { 
                                strRefIds = strRefIds + '\'' + objRefId + '\','; 
                            } 
                            if (strRefIds.length() > 1) { 
                                strRefIds = strRefIds.left(strRefIds.length() - 1); 
                                strRefIds = strRefIds + ')'; 
                            } 
                            mapRelNameWhereIds.put(strRelName, strRefIds); 
                        } 
                        // Query the Name field corresponding to the ID's 
                        Map mapLookupIdName = new Map(); 
                        for (String strRelName : mapRelNameQuery.keyset()) { 
                            String strQuery = mapRelNameQuery.get(strRelName) + ' WHERE Id IN ' + mapRelNameWhereIds.get(strRelName); 
                            for (SObject objSObject : Database.query(strQuery)) { 
                                mapLookupIdName.put(objSObject.Id, (String) objSObject.get('Name')); 
                            } 
                        } 
 
                       for (Id recordId : mapRecIdRelNames.keyset()) { 
                            for (String strRefFieldAPIName : mapRecIdRelNames.get(recordId)) { 
                                sObject newRecord = mapNewRecords.get(recordId); 
                                sObject oldRecord = mapOldRecords.get(recordId); 
                                Custom_History__c customHistory = new Custom_History__c(); 
                                customHistory.Record_Id__c = recordId; 
                                customHistory.Related_To__c = strRelatedTo; 
                                String strFieldAPIName = strRefFieldAPIName.substringBefore(':'); 
                                fieldDescribeResult = mapFieldDescribes.get(strFieldAPIName); 
                                customHistory.Field_Changed__c = fieldDescribeResult.getLabel(); 
                                if (oldRecord.get(strFieldAPIName) != null && mapLookupIdName.get((Id) oldRecord.get(strFieldAPIName)) != null) { 
                                    customHistory.Old_Value__c = mapLookupIdName.get((Id) oldRecord.get(strFieldAPIName)); 
                                } else { 
                                    customHistory.Old_Value__c = ''; 
                                } 
                                if (newRecord.get(strFieldAPIName) != null && mapLookupIdName.get((Id) newRecord.get(strFieldAPIName)) != null) { 
                                    customHistory.New_Value__c = mapLookupIdName.get((Id) newRecord.get(strFieldAPIName)); 
                                } else { 
                                    customHistory.New_Value__c = ''; 
                                } 
                                lstHistoryRecords.add(customHistory); 
                            } 
                        } 
                    }
                } 
                if (!lstHistoryRecords.isEmpty()) { 
                    insert lstHistoryRecords; 
                } 
            } 
        } catch (Exception exp) { 
            System.debug('The following exception has occurred: ' + exp.getMessage()); 
        } 
    } 
} 

Call the logic from Trigger 

Add the following line into your trigger, in my case I am trying to track Account Name, Phone field means, add this line in after trigger logic,

trigger AccountTrigger on Account (after insert, after update) { 
    if(Trigger.isAfter && Trigger.isUpdate) { 
        CustomHistoryTracking.trackHistory('Account', Trigger.newMap, Trigger.oldMap); 
    } 
} 

Test the results

  • Go to any of the Account records and change the Name or Phone field value.
  • Run the below query and you can see that the history records are created and fetched 
SELECT Record_Id__c, Related_To__c, Field_Changed__c, Old_Value__c, New_Value__c, CreatedDate FROM Custom_History__c  

free-consultation