In one sentence: log to a custom object, but publish the log through a platform event configured to publish immediately, because an insert made inside a transaction that then fails is rolled back along with everything else and you lose the record of the failure exactly when you needed it. Capture the message, the type, the stack trace, the request id and the quiddity, and you can answer most support questions without reproducing anything.
The problem with inserting a log record
The obvious pattern fails in the case it exists for.
try {
doTheWork();
} catch (Exception e) {
insert new Error_Log__c(Message__c = e.getMessage());
throw e; // rolls back the log as well
}
Rethrowing rolls the transaction back, and the log row goes with it. Swallowing the exception keeps the log but leaves the data half written. A savepoint does not help either, since the rollback removes the log too. Neither does a future method or a queueable: the enqueue itself is part of the transaction and is discarded with it.
Platform events, published immediately
A platform event with its publish behaviour set to Publish Immediately is delivered when EventBus.publish is called, not when the transaction commits. If the transaction then rolls back, the event has already gone. That is the property the whole pattern rests on.
Create an event, say Error_Event__e, with fields for what you want to record. Then a trigger on that event writes the custom object row in a separate transaction.
public class ErrorLogger {
public static void publish(String context, Exception e) {
EventBus.publish(new Error_Event__e(
Context__c = context,
Exception_Type__c = e.getTypeName(),
Message__c = e.getMessage().abbreviate(255),
Stack_Trace__c = e.getStackTraceString().abbreviate(32000),
Line_Number__c = e.getLineNumber(),
Request_Id__c = Request.getCurrent().getRequestId(),
Quiddity__c = String.valueOf(Request.getCurrent().getQuiddity()),
Running_User__c = UserInfo.getUserId(),
Cpu_Used__c = Limits.getCpuTime(),
Queries_Used__c = Limits.getQueries()
));
}
}
trigger ErrorEventTrigger on Error_Event__e (after insert) {
List<Error_Log__c> logs = new List<Error_Log__c>();
for (Error_Event__e ev : Trigger.new) {
logs.add(new Error_Log__c(
Context__c = ev.Context__c,
Message__c = ev.Message__c,
Stack_Trace__c = ev.Stack_Trace__c,
Request_Id__c = ev.Request_Id__c
));
}
insert logs;
}
The trigger runs as the Automated Process user, in its own transaction, after the original one has finished failing. The log survives.
What to record
The fields that repay themselves in a support queue:
- Request id.
Request.getCurrent().getRequestId()is the same value that appears in debug logs, so a log row can be tied to a trace. - Quiddity. Tells you whether this was a trigger, a Visualforce page, a queueable, a REST call or a batch. Half of “why does it only happen sometimes” is answered by this field alone.
- Exception type and line number, not just the message.
getTypeName()distinguishes aDmlExceptionfrom aNullPointerExceptionat a glance. - Stack trace, truncated to fit. Use a long text area and
abbreviate. - Affected record ids, as a delimited string. This is the field you will wish you had.
- A limits snapshot. CPU and query counts at the point of failure separate a logic bug from a volume problem.
- A context string you control, naming the operation in your own vocabulary.
What not to record: anything the user typed that might be personal data, and full record payloads. A log table is a data retention obligation, and it is also a place people forget to secure.
Keeping the cost down
Publishing one event per failed record in a bulk load will exhaust your event publishing allocation and add nothing. Accumulate failures in a list and publish one event per operation with the details serialised, or publish a bounded number and record a count for the rest.
Give the custom object a retention policy. A log table with three years of rows makes every report slow and every deployment nervous. A scheduled batch that deletes rows older than 90 days is twenty lines of Apex.
Put the log object behind a permission set. The stack traces name your classes and methods, which is more than most users need.
Where to call it
Catch at the boundaries, not everywhere. A service method that cannot do anything useful about a failure should let it propagate. The right places are the entry points: a trigger handler, a REST resource, a queueable’s execute, a batch’s execute, an email service handler. One catch per boundary, with the context string naming that boundary.
A Finalizer attached to a queueable is worth knowing here, because it runs even when the queueable hit an unhandled exception or a limit, and it can tell you which of those happened.
Frequently asked
Does Publish Immediately cost more? It consumes the same event allocation. The difference is delivery timing, not price.
Can I just use System.debug? Only if somebody has a trace flag on at the moment it happens, which for an intermittent overnight failure is never. Debug logs are for development.
Should I log successes too? Sparingly, and at a level you can switch off. An always on audit trail of successful operations grows faster than anyone predicts and buries the failures.
What about the failure of the logger itself? Wrap the publish in its own try block and do nothing in the catch. A logging failure must never replace the original exception, which is the one the user needs to hear about.
