Build a Custom Solution to Track User Record Changes in Salesforce
Salesforce provides standard audit tools to track the changes happening in the Salesforce Org.
Field History Tracking – You can select certain fields to track and show the field history in the History related list of an object. When Field Audit Trail is turned off, Salesforce retains field history data for up to 18 months, and up to 24 months via the API. If Field Audit Trail is turned on, Salesforce retains field history data until you delete it. You can delete field history data manually at any time. Field history tracking data doesn’t count against your data storage limits.
Please Note that this field history tracking is not applicable for the User record changes in Salesforce.
Field Audit Trail – Field Audit Trail extends the capabilities of Field History Tracking, so you can keep field history data indefinitely. Use Field Audit Trail to maintain a comprehensive historical record of field changes (up to 60 tracked fields per object, but can be increased by contacting the Salesforce support team) and to ensure data integrity and compliance with industry standards. Field History Tracking data and Field Audit Trail data don’t count against your data storage limits.
Please Note that the FAT needs to be purchased with Salesforce Shield or by Field Audit Trail licenses (Contact Salesforce for Salesforce Shield Pricing details).
Set up Audit Trail – Setup Audit Trail tracks the recent setup changes that you and other admins make. Audit history is especially useful when there are multiple admins. Audit Trail can retain logs for the past 6 months. The audit trail logs are designed to capture changes made to the configurations of your org, but they don’t track specific changes made to individual fields. You can see the changes like:
User authentication – Logins and logouts are audited at the enterprise level, not at the business unit level.
IP addresses, Changes to users, Changes to user permissions, Changes to Roles,
Changes to Security Settings for all users – Logins, Password Changes, Logouts
Please Note that the Setup Audit log is not able to track User’s field like Company, Department, Title, Address, or any other custom field created on the User object.
Event Monitoring – Event Monitoring in Salesforce is a feature within Salesforce Shield that provides visibility into user activities, security, and performance by tracking events within an org. It helps organizations monitor, audit, and analyze how users interact with their Salesforce data. For audit history and logs, our systems will only show you the past 6 months of historical data. If you are using Event Monitoring, you can then store historical logs for up to 10 years. Please note, Event Monitoring is a paid add-on. It can only show historical data from the day it was enabled. In simpler words, you’ll get to see historical data for 10 years starting today when you attempt to look for the logs in the future.
However, these tools have limitations, such as retention constraints and the inability to comprehensively track all fields of a user record.
In this blog, we will explore how to create a custom solution to track user record modifications beyond Salesforce’s native capabilities.
Why a Custom Solution?
While Salesforce audit logs are useful, they may not capture:
- Changes to records beyond the Field History Tracking limit (20 fields per object).
- Modifications to records in objects like the User Object, where Field History Tracking is not enabled.
- Custom logging requirements for compliance and reporting needs.
- Changes across multiple objects in a unified view.
Approach to Building a Custom Tracking User History
To track record changes effectively, we can use a combination of VF, LWC, Apex Triggers, Custom Object & Fields, and Reports. Below are the key components of the solution:
1. Create a Custom Object for User History Tracking
We need a dedicated custom object to store the User History with fields such as:
Object Name/Label | Object API | Plural Label |
User History Tracking | User_History_Tracking__c | User History Trackings |
User History Tracking Name | Name | Name Auto Number UH-{000000} |
Field Label | Field API | Field Data Type |
Field Updated | Field_Updated__c | Text(255) |
New Value | New_Value__c | Text(255) |
Old Value | Old_Value__c | Text(255) |
Update By | Updated_By__c | Lookup(User) |
User | User__c | Lookup(User) |
2. Develop an Apex Trigger for Tracking Changes on the User Object
Trigger logic on the User Object to create the User History record in the Custom object after any update is done on the user record.
Note: Since we are updating a Setup Object that is User object and inserting a Non-Setup Object (such as the custom User_History_Tracking__c
object) within the same transaction results in a Mixed DML Operation Error, utilizing a @future method to handle the insertion of User_History_Tracking__c
Records asynchronously is an appropriate solution.
So we have created a class UserTriggerFuture, and inside the class a Future method where we are going to create the Non-Setup object User History Tracking records.
trigger UserTrigger on User (after update)
{
List<User_History_Tracking__c> userHistoryList = new List<User_History_Tracking__c>();
Map<Id,UserRole> roleMap = new Map<Id,UserRole>([SELECT id, name from UserRole]);
Map<Id,Profile> profileMap = new Map<Id,Profile>([SELECT id, name from Profile]);
if(trigger.IsAfter && Trigger.isUpdate){
List<String> reqFields = new List<String>();
Map <String,Schema.SObjectType> gd = Schema.getGlobalDescribe();
Schema.SObjectType sobjType = gd.get('User');
Schema.DescribeSObjectResult r = sobjType.getDescribe();
Map<String, Schema.SObjectField> MapofField = r.fields.getMap();
for(String fieldName : MapofField.keySet()) {
Schema.SObjectField field = MapofField.get(fieldName);
Schema.DescribeFieldResult F = field.getDescribe();
reqFields.add(String.valueOf(field));
}
System.debug(reqFields);
for(String keyfieldValue : reqFields){
String keyfield = keyfieldValue;
system.debug('keyfield --'+keyfield);
for(User us : Trigger.New){
User OldUser = Trigger.oldMap.get(us.id);
User NewUser = Trigger.newMap.get(us.id);
if(OldUser.get(keyfield) != NewUser.get(keyfield) && (keyfield != 'LastModifiedDate' && keyfield != 'SystemModstamp')){
User_History_Tracking__c usrHistoryrec = new User_History_Tracking__c();
usrHistoryrec.Field_Updated__c = keyfield;
usrHistoryrec.User__c = us.Id;
usrHistoryrec.Updated_By__c = UserInfo.getUserId();
if(keyfield == 'UserRoleId'){
system.debug('--UserRoleId');
usrHistoryrec.Old_Value__c = String.valueOf(OldUser.get(keyfield)) != Null ? roleMap.get(String.valueOf(OldUser.get(keyfield))).Name : ' ';
usrHistoryrec.New_Value__c = keyfield == 'UserRoleId' ? roleMap.get(String.valueOf(NewUser.get(keyfield))).Name : String.valueOf(NewUser.get(keyfield));
}else if(keyfield == 'ProfileId'){
system.debug('--ProfileId');
usrHistoryrec.Old_Value__c = String.valueOf(OldUser.get(keyfield))!= Null ? profileMap.get(String.valueOf(OldUser.get(keyfield))).Name : ' ';
usrHistoryrec.New_Value__c = keyfield == 'ProfileId' ? profileMap.get(String.valueOf(NewUser.get(keyfield))).Name : String.valueOf(NewUser.get(keyfield));
}
else{
usrHistoryrec.Old_Value__c = String.valueOf(OldUser.get(keyfield));
usrHistoryrec.New_Value__c = String.valueOf(NewUser.get(keyfield));
}
System.debug('Old value'+OldUser.get(keyfield) +' New value '+NewUser.get(keyfield));
userHistoryList.add(usrHistoryrec);
}
}
}
if(!userHistoryList.isEmpty()){
String jsonResponse = JSON.serialize(userHistoryList);
UserTriggerFuture.handleUserHistoryMethod(jsonResponse);
}
}
}
Future to avoid the Mixed DML Operation error
public class UserTriggerFuture {
@future
public Static void handleUserHistoryMethod(String jsonResponse){
// Deserializing the JSON data into a list of Account objects
List<User_History_Tracking__c> userHistoryList = (List<User_History_Tracking__c>)JSON.deserialize(jsonResponse, List<User_History_Tracking__c>.class);
insert userHistoryList ;
}
}
Test Class
@isTest
public class UserTriggerTest {
@testSetup
static void setupTestData() {
Profile p = [SELECT Id FROM Profile WHERE Name='Standard User'];
List<User> uu = new List<User>();
while (uu.size() < 5) {
Blob b = Crypto.GenerateAESKey(128);
String h = EncodingUtil.ConvertTohex(b);
String uid = h.SubString(0,8);
User u = new User(Alias = uid, Email= uid + '@testdomain.com',
EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US',
LocaleSidKey='en_US', ProfileId = p.Id,
TimeZoneSidKey='America/New_York', UserName= uid + '@testdomain.com');
uu.add(u);
}
insert uu;
}
@isTest
static void testSearchForAccounts() {
User usr = [Select id,name,Alias,LastName from user where LastName = 'Testing' limit 1];
usr.LastName = 'StdUsr';
update usr;
}
}
3. We can also show the User History on the User record Page layout
To show the User history record on the User record page layout, we will build an LWC but there is an issue in this approach. That is, we cannot directly expose our User history LWC on User page layout. So we need to build a Visualforce page and inside the VF page we need to create/call the LWC component to show the currently open user’s History record in tabular form.
First, we will build a Visualforce Page where we can call out the Lightning AURA application and inside the AURA application, we can call our LWC.
Visualforce Page Name – UserHistoryTrackingVF
<apex:page standardController="User">
<!-- This is used to include the JS files that makes embedding a Lightning App in VF page possible -->
<apex:includeLightning />
<!-- This is where the component gets embeded -->
<div id="lightning" />
<script>
//1. Specify the Lightning App
$Lightning.use("c:UserHistoryTrackingAuraApp", function () {
//2. Specify the Aura Component that has LWC in it
$Lightning.createComponent("c:userHistoryTrackingLwc",
{
"recordId": '{!$CurrentPage.parameters.Id}'
},
"lightning",
function (cmp) {
console.log("Cmp Loaded");
//do some stuff
}
);
});
</script>
</apex:page>
AURA Application Name – UserHistoryTrackingAuraApp
<aura:application extends="ltng:outApp">
<h2>User History</h2>
<c:userHistoryTrackingLwc></c:userHistoryTrackingLwc>
</aura:application>
Apex Class Name – UserHistoryTrackingCTRL
public with sharing class UserHistoryTrackingCTRL {
public UserHistoryTrackingCTRL() {
}
@AuraEnabled(cacheable=true)
public static List<User_History_Tracking__c> getUserHistoryRecord(String UserId){
List<User_History_Tracking__c> userHistoryList = new List<User_History_Tracking__c>();
userHistoryList = [SELECT id, name,Field_Updated__c,Old_Value__c,New_Value__c,User__c,User__r.name,Updated_By__c,Updated_By__r.name from User_History_Tracking__c where User__c =: UserId order by CreatedDate desc];
return userHistoryList;
}
}
LWC Name – paginatorTable HTML File
<template>
<template lwc:if={hasData}>
<div class="slds-grid slds-grid_vertical-align-center slds-grid_align-spread">
<div class="slds-col"><!--RECORDS PER PAGE-->
<div style={controlPagination} class="slds-list_inline slds-p-bottom_xx-small customSelect">
<label class="slds-text-color_weak slds-p-horizontal_x-small" for="recordsPerPage">Records per page:</label>
<div class="slds-select_container">
<select class="slds-select" id="recordsPerPage" onchange={handleRecordsPerPage}>
<template for:each={pageSizeOptions} for:item="option">
<option key={option} value={option}>{option}</option>
</template>
</select>
</div>
</div>
</div>
<div class="slds-col"><!--SEARCH BOX-->
<div if:true={showSearchBox}>
<div class="slds-p-horizontal_x-small slds-p-bottom_xx-small">
<lightning-input label="" type="search" placeholder="Search by any Col Value" variant="label-hidden" onchange={handleKeyChange}></lightning-input>
</div>
</div>
</div>
<div class="slds-col"><!--PAGE NAVIGATION-->
<div style={controlPagination}>
<div class="slds-col slds-p-bottom_xx-small">
<span style={controlPrevious}>
<lightning-button-icon icon-name="utility:left" variant="bare" size="medium" alternative-text="Previous Page" onclick={previousPage}></lightning-button-icon>
</span>
<label class="slds-text-color_weak slds-p-horizontal_x-small" for="pageNum">Page:</label>
<input type="number" id="pageNum" value={pageNumber} maxlength="4" onkeypress={handlePageNumberChange} class="customInput" title="Go to a Page"></input>
<span> of <b id="totalPages">{totalPages}</b></span>
<span style={controlNext}>
<lightning-button-icon icon-name="utility:right" variant="bare" size="medium" alternative-text="Next Page" onclick={nextPage} class="slds-p-horizontal_x-small"></lightning-button-icon>
</span>
</div>
</div>
</div>
</div>
</template>
</template>
PaginatorTable JS file
import { LightningElement, api, track} from 'lwc';
const DELAY = 300;
const recordsPerPage = [5,10,25,50,100];
const pageNumber = 1;
const showIt = 'visibility:visible';
const hideIt = 'visibility:hidden'; //visibility keeps the component space, but display:none doesn't
export default class PaginatorTable extends LightningElement {
@api showSearchBox = false; //Show/hide search box; valid values are true/false
@api showPagination; //Show/hide pagination; valid values are true/false
@api pageSizeOptions = recordsPerPage; //Page size options; valid values are array of integers
@api totalRecords; //Total no.of records; valid type is Integer
@api records; //All records available in the data table; valid type is Array
@track pageSize; //No.of records to be displayed per page
@track totalPages; //Total no.of pages
@track pageNumber = pageNumber; //Page number
@track searchKey; //Search Input
@track controlPagination = showIt;
@track controlPrevious = hideIt; //Controls the visibility of Previous page button
@track controlNext = showIt; //Controls the visibility of Next page button
recordsToDisplay = []; //Records to be displayed on the page
@api hasData = false;
//Called after the component finishes inserting to DOM
connectedCallback() {
if(this.pageSizeOptions && this.pageSizeOptions.length > 0)
this.pageSize = this.pageSizeOptions[0];
else{
this.pageSize = this.totalRecords;
this.showPagination = false;
}
this.controlPagination = this.showPagination === false ? hideIt : showIt;
this.setRecordsToDisplay();
}
handleRecordsPerPage(event){
this.pageSize = event.target.value;
this.setRecordsToDisplay();
}
handlePageNumberChange(event){
if(event.keyCode === 13){
this.pageNumber = event.target.value;
this.setRecordsToDisplay();
}
}
previousPage(){
this.pageNumber = this.pageNumber-1;
this.setRecordsToDisplay();
}
nextPage(){
this.pageNumber = this.pageNumber+1;
this.setRecordsToDisplay();
}
setRecordsToDisplay(){
this.recordsToDisplay = [];
if(!this.pageSize)
this.pageSize = this.totalRecords;
this.totalPages = Math.ceil(this.totalRecords/this.pageSize);
this.setPaginationControls();
for(let i=(this.pageNumber-1)*this.pageSize; i < this.pageNumber*this.pageSize; i++){
if(i === this.totalRecords) break;
this.recordsToDisplay.push(this.records[i]);
}
this.dispatchEvent(new CustomEvent('paginatorchange', {detail: this.recordsToDisplay})); //Send records to display on table to the parent component
}
setPaginationControls(){
//Control Pre/Next buttons visibility by Total pages
if(this.totalPages === 1){
this.controlPrevious = hideIt;
this.controlNext = hideIt;
}else if(this.totalPages > 1){
this.controlPrevious = showIt;
this.controlNext = showIt;
}
//Control Pre/Next buttons visibility by Page number
if(this.pageNumber <= 1){
this.pageNumber = 1;
this.controlPrevious = hideIt;
}else if(this.pageNumber >= this.totalPages){
this.pageNumber = this.totalPages;
this.controlNext = hideIt;
}
//Control Pre/Next buttons visibility by Pagination visibility
if(this.controlPagination === hideIt){
this.controlPrevious = hideIt;
this.controlNext = hideIt;
}
}
handleKeyChange(event) {
window.clearTimeout(this.delayTimeout);
const searchKey = event.target.value;
if(searchKey){
this.delayTimeout = setTimeout(() => {
this.controlPagination = hideIt;
this.setPaginationControls();
this.searchKey = searchKey;
//Use other field name here in place of 'Name' field if you want to search by other field
//this.recordsToDisplay = this.records.filter(rec => rec.includes(searchKey));
//Search with any column value (Updated as per the feedback)
this.recordsToDisplay = this.records.filter(rec => JSON.stringify(rec).includes(searchKey));
if(Array.isArray(this.recordsToDisplay) && this.recordsToDisplay.length > 0)
this.dispatchEvent(new CustomEvent('paginatorchange', {detail: this.recordsToDisplay})); //Send records to display on table to the parent component
}, DELAY);
}else{
this.controlPagination = showIt;
this.setRecordsToDisplay();
}
}
}
PaginatorTable CSS File
.customSelect select {
padding-right: 1.25rem;
min-height: inherit;
line-height: normal;
height: 1.4rem;
}
.customSelect label {
margin-top: .1rem;
}
.customSelect .slds-select_container::before {
border-bottom: 0;
}
.customInput {
width: 3rem;
height: 1.4rem;
text-align: center;
border: 1px solid #dddbda;
border-radius: 3px;
background-color:#fff;
}
LWC Name – userHistoryTrackingLwc HTML file
<template>
<lightning-card title="User History">
<template lwc:if={showTable}>
<c-paginator-table records={userHistory}
has-data ={showTable}
total-records={userHistory.length}
show-search-box="true"
onpaginatorchange={handlePaginatorChange}>
</c-paginator-table>
<lightning-datatable key-field="Id"
data={recordsToDisplay}
columns={columns}
hide-checkbox-column
show-row-number-column
row-number-offset={rowNumberOffset}>
</lightning-datatable>
</template>
<template lwc:else>
There is not user history for this User
</template>
</lightning-card>
</template>
UserHistoryTrackingLwc JS file
import { LightningElement,api,wire,track } from 'lwc';
import getUserHistoryRecord from'@salesforce/apex/UserHistoryTrackingCTRL.getUserHistoryRecord';
// datatable columns with row actions. Set sortable = true
const columns = [ { label: 'Field Updated', fieldName: 'Field_Updated__c'},
{ label: 'Old Value', fieldName: 'Old_Value__c'},
{ label: 'New Value', fieldName: 'New_Value__c'},
{ label: 'Updated By', fieldName: 'updatedBy'},
{ label:'User', fieldName: 'userLink', type: 'url', typeAttributes: {label: {fieldName: 'userName'}, tooltip:'Go to detail page', target: '_blank'}}
];
export default class UserHistoryTrackingLwc extends LightningElement {
@api recordId;
@track error;
@track columns = columns;
@track userHistory; //All opportunities available for data table
@track showTable = false; //Used to render table after we get the data from apex controller
@track recordsToDisplay = []; //Records to be displayed on the page
@track rowNumberOffset; //Row number
constructor() {
super();
}
connectedCallback() {
console.log('recordId --- >'+this.recordId);
}
@wire(getUserHistoryRecord,{UserId: '$recordId'})
handleUserHistory({error,data}){
if(data){
if(data.length != 0){
let recs = [];
console.log('user history data',JSON.stringify(data));
console.log('size ',data.length);
for(let i=0; i<data.length; i++){
let history = {};
history.rowNumber = ''+(i+1);
history.userLink = '/'+data[i].User__c;
history.userName = data[i].User__r.Name;
history.updatedBy = data[i].Updated_By__r.Name;
history = Object.assign(history, data[i]);
recs.push(history);
}
this.userHistory= recs;
this.showTable = true;
}
}else{
console.log('user history data',JSON.stringify(error));
this.error = error;
}
}
//Capture the event fired from the paginator component
handlePaginatorChange(event){
this.recordsToDisplay = event.detail;
if(this.showTable == false){
console.log('No data --');
this.rowNumberOffset = 1;
}else{
this.rowNumberOffset = this.recordsToDisplay[0].rowNumber-1;
}
}
}
Note: Since we are opening the LWC directly from the VF Page, there is nothing that has to be added to the js-meta.xml file.
Following the creation of all the above components (object, fields, apex class, VF page, Aura App, LWC), we will add our VF page to the User Page Layout, which will display the User history records in tabular form.
Go to the object from Object Manager -> Click User object -> Click User Page layouts and open the User Layout.

Add a new Section Name “User History”, and click on Visualforce Pages. Now drag and drop UserHistoryTrackingVF onto the new Section and then click the Save button on the Page layout.


Now, whenever an administrator/user modifies any user record, a User History Tracking record is automatically created, and we can view the history of all changes on the user record page.

4. Automate Data Retention with Scheduled Batch Jobs
Since User History data can grow significantly over time, it’s good practice to implement a batch job to archive in a Big Object or delete logs older than a certain period.
5. Build Reports and Dashboards
Create reports on User History Tracking to analyze user modifications effectively. Use filters, groupings, and dashboards to visualize changes across the organization.
Benefits of This Approach
- Tracks unlimited fields for the user object.
- Provides real-time logging of user changes.
- Can be extended/reused for multiple objects.
- Supports compliance and auditing requirements for the Company.
- Enables reporting on User history.