Logging Salesforce errors with platform events

The following pattern is fairly common in other technologies:

  try {
    doSomethingRisky();
  } catch (Exception e) {
    logException(e);
    throw e;
  }

This won’t work in salesforce. By rethrowing the exception (assuming it’s not caught further up the call stack), the database transaction will be rolled back - that includes whatever insert or update ws triggered in logException(e). So how do we ensure that the logging occurs in a separate database transaction? The answer is a platform event!

As usual, feel free to “cut to the chase” if you just want the working solution.

What NOT to do (it won’t work)

Use a @future method

If you run into this logging dilemma, you may think to abstract the logging logic to a future method, but this won’t work either. The asynchronous nature of future methods doesn’t save us due to the way - or more accurately, when - they are invoked. In the case of an unhandled exception, they simply aren’t invoked at all.

When synchronous Apex calls a future methods, they are queued behind the scenes until after the current transaction is committed. Its only after the transaction is committed that each queued future method is invoked. If the transaction isn’t committed (e.g. if its rolled back due to an unhandled exception), the future methods are never invoked.

Skip the rethrow

The novice may be tempted to simply avoid the rethrow at the end of the catch block - DON’T DO IT!

I see this A LOT and it keeps me up at night… Simply logging an exception does not mean it is handled! As a matter of best practice, any exception that isn’t resolved throw an alternate logic path should be allowed to bubble up so it can be handled at the top of the call stack. Whether your code is invoked by some UI interaction, automation, or a scheduled process, burying exceptions will always leave you with a mess on your hands.

Using a platform event

Platform events are essentially just special custom objects, but crucially, they can be configured to be persisted before a transaction is committed.

The platform event solution requires a bit of extra configuration, but it isn’t too complicated - here’s the basic idea:

  • Duplicate your custom log object as a platform event
  • Instead of creating/persisting an instance of your log object, create/publish the platform event
  • Define an event handler/trigger to respond to the platform event and turn it into a log record

As an example, lets say we want to log failed callouts to a custom object called CalloutError__c. To keep it simple, we’ll just log a name to identify the endpoint, an error message, and the date/time of the error.

1. Create a platform event

The platform event should mirror your log object. Any field you intend to populate on the log object should also exist on the platform event.

Platform Event

Most importantly, the Publish Behavior of the created event must be set to Publish Immediately. This is what ensures that the events are persisted regardless of success or failure of the transaction from which they originate.

Platform Event

2. Implement the logging logic

I like to abstract the logic behind generating/publishing the platform event, as well as handling it, to a service class. This ensures that you don’t repeat yourself, and keeps everything in one place.

public with sharing class CalloutErrorService {

	/*
	 * Generates a Callout Error platform event, which will cause the asynchronous
	 * creation of a Callout Error record.
	 * @param endpoint The endpoint for which a callout error occurred
	 * @param message An error message
	 * @param errorDate The date/time the error occurred 
	 */
	public void persistError(String endpoint, String message, Datetime errorDate) {
		// Create and publish error event
		CalloutError__e errorEvent = new CalloutError__e(
			Endpoint__c = endpoint,
			Message__c = message,
			Date__c = errorDate
		);
		Database.SaveResult result = EventBus.publish(errorEvent);

		// Check result of publish for error
		if (result.isSuccess()) {
			System.debug('Successfully published callout error event');
		} else {
			System.debug('Failed to publish callout error event:');
			for (Database.Error error : result.getErrors()) {
				System.debug(error.getStatusCode() + ' - ' + error.getMessage());
			}
		}
	}

	/**
	 * Handles Callout Error events, persisting a Callout Error record for each event processed.
	 * @param errorEvents A list of Platform Error events to be handled
	 */
	public void handleCalloutErrorEvent(List<CalloutError__e> errorEvents) {
		List<CalloutError__e> errors = new List<CalloutError__e>();
		for (CalloutError__e event : errorEvents) {
			errors.add(new CalloutError__c(
				Endpoint__c = event.Endpoint__c,
				Message__c = event.Message__c,
				Date__c = event.Date__c,
			));
		}
		insert errors;
	}
}

3. Create an event handler trigger to process each platform event

We’ve already implemented the logic behind the event handler - we just need to create the trigger and invoke the CalloutErrorService.

trigger CalloutErrorEventHandler on CalloutError__e (after insert) {
	System.debug('Handling callout error events');
	CalloutErrorService errorService = new CalloutErrorService();
	errorService.handleCalloutErrorEvent(trigger.new);
}

Wrapping up

That’s all there is to it. In typical Salesforce fashion, effective error logging is not intuitive, but it’s also not particularly difficult if you know the right approach.

If you found this helpful or interesting, leave a comment. I’d love to hear if you’ve used this approach or something similar in your own project.

And if you’d like to learn more about what we’re all about at Restless Labs, or if you need some development or architectural assistance in an upcoming project drop us a message. We’ve spent more than a decade designing and implementing custom solutions in a variety of industries and are always looking to a new challenge!