Skip to content

Commit

Permalink
Adding new plugin to add additional logging options for async failures (
Browse files Browse the repository at this point in the history
#309)

* Adding new plugin for BatchApexErrorEvent and default Finalizer implementation for logging failures
* Code review feedback - updated test class to make use of User metadata to avoid having to perform DML/the extra logs associated with Account, and updated example image in README to correctly describe the process for enabling unexpected batch error logging
  • Loading branch information
jamessimone authored May 9, 2022
1 parent b81ea20 commit 520a385
Show file tree
Hide file tree
Showing 19 changed files with 401 additions and 8 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ Designed for Salesforce admins, developers & architects. A robust logger for Ape

## Unlocked Package - v4.7.2

[![Install Unlocked Package in a Sandbox](./images/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015lh4QAA)
[![Install Unlocked Package in Production](./images/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015lh4QAA)
[![Install Unlocked Package in a Sandbox](./images/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015lhEQAQ)
[![Install Unlocked Package in Production](./images/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015lhEQAQ)
[![View Documentation](./images/btn-view-documentation.png)](https://jongpie.github.io/NebulaLogger/)

## Managed Package - v4.7.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
<behavior>Required</behavior>
<field>SObjectHandlerApexClass__c</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>SObjectTypeOverride__c</field>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>SObjectTypeOverride__c</fullName>
<description
>Not all base platform types can be selected using the SObjectType picklist. If your object is not supported, supply the API name for the object here instead.</description>
<externalId>false</externalId>
<fieldManageability>SubscriberControlled</fieldManageability>
<inlineHelpText
>Not all base platform types can be selected using the SObjectType picklist. If your object is not supported, supply the API name for the object here instead.</inlineHelpText>
<label>SObjectType Override</label>
<length>255</length>
<required>false</required>
<type>Text</type>
<unique>false</unique>
</CustomField>
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<referenceTo>EntityDefinition</referenceTo>
<relationshipLabel>Logger SObject Handler Configurations</relationshipLabel>
<relationshipName>LoggerSObjectHandlerConfigurations</relationshipName>
<required>true</required>
<required>false</required>
<type>MetadataRelationship</type>
<unique>true</unique>
</CustomField>
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,12 @@ public without sharing abstract class LoggerSObjectHandler {
private static Map<Schema.SObjectType, LoggerSObjectHandler__mdt> queryHandlerConfigurations() {
Map<Schema.SObjectType, LoggerSObjectHandler__mdt> sobjectTypeToHandlerConfiguration = new Map<Schema.SObjectType, LoggerSObjectHandler__mdt>();
for (LoggerSObjectHandler__mdt handlerConfiguration : [
SELECT IsEnabled__c, SObjectHandlerApexClass__c, SObjectType__r.QualifiedApiName
SELECT IsEnabled__c, SObjectHandlerApexClass__c, SObjectType__r.QualifiedApiName, SObjectTypeOverride__c
FROM LoggerSObjectHandler__mdt
WHERE IsEnabled__c = TRUE
]) {
handlerConfiguration.SObjectType__c = handlerConfiguration.SObjectType__r.QualifiedApiName;
Schema.SObjectType sobjectType = ((SObject) Type.forName(handlerConfiguration.SObjectType__c).newInstance()).getSObjectType();
Schema.SObjectType sobjectType = prepHandlerType(handlerConfiguration);
sobjectTypeToHandlerConfiguration.put(sobjectType, handlerConfiguration);
}

Expand All @@ -266,15 +266,22 @@ public without sharing abstract class LoggerSObjectHandler {
return sobjectTypeToHandlerConfiguration;
}

private static Schema.SObjectType prepHandlerType(LoggerSObjectHandler__mdt handlerConfiguration) {
if (String.isNotBlank(handlerConfiguration.SObjectTypeOverride__c)) {
handlerConfiguration.SObjectType__c = handlerConfiguration.SObjectTypeOverride__c;
}

return ((SObject) Type.forName(handlerConfiguration.SObjectType__c).newInstance()).getSObjectType();
}

@TestVisible
private static LoggerSObjectHandler__mdt getHandlerConfiguration(Schema.SObjectType sobjectType) {
return SOBJECT_TYPE_TO_HANDLER_CONFIGURATIONS.get(sobjectType);
}

@TestVisible
private static void setMock(LoggerSObjectHandler__mdt handlerConfiguration) {
//TODO cleanup code duplication
Schema.SObjectType sobjectType = ((SObject) Type.forName(handlerConfiguration.SObjectType__c).newInstance()).getSObjectType();
Schema.SObjectType sobjectType = prepHandlerType(handlerConfiguration);
SOBJECT_TYPE_TO_HANDLER_CONFIGURATIONS.put(sobjectType, handlerConfiguration);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ private class LoggerSObjectHandler_Tests {
);
}

@IsTest
static void it_should_return_handler_via_override() {
Schema.SObjectType sobjectType = new MockSObjectHandler().getSObjectType();
LoggerSObjectHandler.setMock(
new LoggerSObjectHandler__mdt(
IsEnabled__c = true,
SObjectHandlerApexClass__c = MockSObjectHandler.class.getName(),
SObjectTypeOverride__c = sobjectType.getDescribe().getName()
)
);

LoggerSObjectHandler configuredInstance = LoggerSObjectHandler.getHandler(sobjectType);

System.assertEquals(
true,
configuredInstance instanceof MockSObjectHandler,
'The handler returned via override should be an instance of the configured class, MockSObjectHandler'
);
}

@IsTest
static void it_should_return_default_sobject_handler_implementation_when_no_configuration_provided() {
Schema.SObjectType sobjectType = new MockDefaultImplementationSObjectHandler().getSObjectType();
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 52 additions & 0 deletions nebula-logger/plugins/async-failure-additions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Async Failure Additions plugin for Nebula Logger

> :information_source: This plugin requires `v4.7.1` or newer of Nebula Logger's unlocked package
[![Install Unlocked Package](../.images/btn-install-unlocked-package-plugin-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=TODO)

## What's Included

### Unexpected Batch Error Logging

By default, this plugin adds support for logging unexpected Batch class failures in Apex. All a batch class needs to do is implement the marker `Database.RaisesPlatformEvents` interface _and_ create a `LoggerParameter__mdt` record where the `Value` field matches the name of the batch class you are looking to add logging for, and the DeveloperName (the "Name" field) starts with `BatchError`:

```java
// the class MUST implement Database.RaisesPlatformEvents for this to work correctly!
public class MyExampleBatchable implements Database.Batchable<SObject>, Database.RaisesPlatformEvents {
// etc ...
}
```

And the CMDT record:

![Setting up the Logger Parameter record to opt into unexpected Batch failures](.images/opt-into-batch-logging-with-logger-parameter.png)

Once you've correctly configured those two things (the marker interface `Database.RaisesPlatformEvents` on the Apex batchable class, and the Logger Parameter CMDT record), your class will now log any uncaught exceptions that cause that batch class to fail unexpectedly.

---

If you want to customize additional behavior off of the trigger that subscribes to `BatchApexErrorEvent`, you can do so by creating a new Trigger SObject Handler CMDT record using the `TriggerSObjectHandler__mdt.SObjectTypeOverride__c` field (since `BatchApexErrorEvent` isn't one of the supported Entity Definition picklist results). The Logger SObject Handler Name should correspond to a valid instance of `LoggerSObjectHandler` - the instance of `LogBatchApexEventHandler` included in this plugin shows what an example logging implementation for unexpected failures would look like, if you want to go down that route.

### Queueable Error Logging

If you have Apex classes that implement `System.Queueable`, you can add error logging with some minimal code additions:

```java
public class MyExampleQueueable implements System.Queueable {
public void execute (System.QueueableContext qc) {
System.attachFinalizer(new LogFinalizer());
}
}
```

If you'd like to do _additional_ processing, you can alternatively choose to _extend_ `LogFinalizer`:

```java
public class MyCustomFinalizer extends LogFinalizer {
protected override void innerExecute(System.FinalizerContext fc) {
// do whatever you'd like!
// errors will be logged automatically in addition to what you choose to do here
// no need to call Logger.saveLog() manually on this code path
}
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//------------------------------------------------------------------------------------------------//
// This file is part of the Nebula Logger project, released under the MIT License. //
// See LICENSE file or go to https://github.com/jongpie/NebulaLogger for full license details. //
//------------------------------------------------------------------------------------------------//
/**
* @group Plugins
* @description `BatchApexErrorEvent` handler to log unexpected batch errors for classes that implement `Database.RaisesPlatformEvents` and opt into processing via `LoggerParameter__mdt`
* @see LoggerSObjectHandler
*/
public without sharing class LogBatchApexErrorEventHandler extends LoggerSObjectHandler {
public static final String BATCH_ERROR_LOGGER = 'BatchError';
public static final String LOG_MESSAGE = 'An unexpected job error occurred: {0} with exception type: {1} and message: {2} during batch phase: {3}.\nStacktrace: {4}';
private static Boolean shouldSaveLogs = false;

private List<BatchApexErrorEvent> batchApexErrorEvents;

/**
* @description Opts into the default constructor
*/
public LogBatchApexErrorEventHandler() {
super();
}

public override Schema.SObjectType getSObjectType() {
return Schema.BatchApexErrorEvent.SObjectType;
}

protected override void executeAfterInsert(List<SObject> triggerNew) {
this.batchApexErrorEvents = (List<BatchApexErrorEvent>) triggerNew;
this.handleJobErrors();
}

private void handleJobErrors() {
Set<Id> asyncApexJobIds = new Set<Id>();
for (BatchApexErrorEvent evt : this.batchApexErrorEvents) {
asyncApexJobIds.add(evt.AsyncApexJobId);
}

Map<Id, AsyncApexJob> jobIdToClass = new Map<Id, AsyncApexJob>([SELECT Id, ApexClass.Name FROM AsyncApexJob WHERE Id IN :asyncApexJobIds]);
Logger.error('Batch job terminated unexpectedly');
for (BatchApexErrorEvent errorEvent : this.batchApexErrorEvents) {
shouldSaveLogs = this.getShouldSaveLogs(jobIdToClass, errorEvent);
LogMessage logMessage = new LogMessage(
LOG_MESSAGE,
new List<String>{ errorEvent.AsyncApexJobId, errorEvent.ExceptionType, errorEvent.Message, errorEvent.Phase, errorEvent.StackTrace }
);
Logger.error(logMessage);
}
if (shouldSaveLogs) {
Logger.saveLog();
}
}

private Boolean getShouldSaveLogs(Map<Id, AsyncApexJob> jobIdToClass, BatchApexErrorEvent errorEvent) {
if (shouldSaveLogs == false) {
AsyncApexJob job = jobIdToClass.get(errorEvent.AsyncApexJobId);
List<LoggerParameter__mdt> configurationList = LoggerParameter.matchOnPrefix(BATCH_ERROR_LOGGER);
for (LoggerParameter__mdt config : configurationList) {
if (config.Value__c == job?.ApexClass.Name) {
shouldSaveLogs = true;
break;
}
}
}
return shouldSaveLogs;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<status>Active</status>
</ApexClass>
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//------------------------------------------------------------------------------------------------//
// This file is part of the Nebula Logger project, released under the MIT License. //
// See LICENSE file or go to https://github.com/jongpie/NebulaLogger for full license details. //
//------------------------------------------------------------------------------------------------//
@SuppressWarnings('PMD.ApexDoc, PMD.ApexAssertionsShouldIncludeMessage, PMD.MethodNamingConventions, PMD.ApexUnitTestClassShouldHaveAsserts')
@IsTest(IsParallel=true)
private class LogBatchApexErrorEventHandler_Tests implements Database.Batchable<SObject>, Database.RaisesPlatformEvents {
private enum Phase {
START,
EXECUTE,
FINISH
}

private final Phase throwLocation;

@IsTest
static void it_should_create_log_when_batch_job_throws_in_start_method() {
runTestForPhase(Phase.START);
}

@IsTest
static void it_should_create_log_when_batch_job_throws_in_execute_method() {
runTestForPhase(Phase.EXECUTE);
}

@IsTest
static void it_should_create_log_when_batch_job_throws_in_finish_method() {
runTestForPhase(Phase.FINISH);
}

@SuppressWarnings('PMD.EmptyCatchBlock')
private static void runTestForPhase(Phase phase) {
Logger.getUserSettings().IsApexSystemDebugLoggingEnabled__c = false;
LoggerParameter__mdt mockParam = new LoggerParameter__mdt();
mockParam.Value__c = LogBatchApexErrorEventHandler_Tests.class.getName();
mockParam.DeveloperName = LogBatchApexErrorEventHandler.BATCH_ERROR_LOGGER + 'Test';
LoggerParameter.setMock(mockParam);
try {
System.Test.startTest();
Database.executeBatch(new LogBatchApexErrorEventHandler_Tests(phase));
System.Test.stopTest();
} catch (Exception ex) {
// via https://salesforce.stackexchange.com/questions/263419/testing-batchapexerrorevent-trigger
}
// at this point, we're still two async-levels deep into Platform Event-land; we need to call "deliver()" twice
System.Test.getEventBus().deliver(); // fires the platform event for Database.RaisesPlatformEvents
System.Test.getEventBus().deliver(); // fires the logger's platform event

assertLogWasCreatedForPhase(phase);
}

private static void assertLogWasCreatedForPhase(Phase phase) {
Log__c log = getLog();
System.assertNotEquals(null, log, 'Log should have been created!');
System.assertEquals(2, log.LogEntries__r.size(), 'Two log entries should have been created');
System.assertEquals('Batch job terminated unexpectedly', log.LogEntries__r[0].Message__c);
System.assertEquals(
String.format(
LogBatchApexErrorEventHandler.LOG_MESSAGE,
new List<String>{ 'someId', 'System.IllegalArgumentException', phase.name(), phase.name(), 'stacktrace' }
)
.subStringAfter('with')
.substringBefore('Stacktrace:'),
log.LogEntries__r[1].Message__c.substringAfter('with').substringBefore('Stacktrace:')
);
}

/**
* the `BatchApexErrorEvent` type has a property, `Phase` with three possible values:
* - START
* - EXECUTE
* - FINISH
*/
public LogBatchApexErrorEventHandler_Tests(Phase throwLocation) {
this.throwLocation = throwLocation;
}

public Database.QueryLocator start(Database.BatchableContext bc) {
throwOnLocationMatch(Phase.START);
return Database.getQueryLocator([SELECT Id FROM User LIMIT 1]);
}

public void execute(Database.BatchableContext bc, List<SObject> scope) {
throwOnLocationMatch(Phase.EXECUTE);
}

public void finish(Database.BatchableContext bc) {
throwOnLocationMatch(Phase.FINISH);
}

private void throwOnLocationMatch(Phase phase) {
if (this.throwLocation == phase) {
throw new IllegalArgumentException(this.throwLocation.name());
}
}

private static Log__c getLog() {
return [SELECT Id, (SELECT Message__c FROM LogEntries__r) FROM Log__c];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<status>Active</status>
</ApexClass>
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//------------------------------------------------------------------------------------------------//
// This file is part of the Nebula Logger project, released under the MIT License. //
// See LICENSE file or go to https://github.com/jongpie/NebulaLogger for full license details. //
//------------------------------------------------------------------------------------------------//
/**
* @group Plugins
* @description `System.Finalizer` implementation that can be used by subscribers to log errors
*/
public without sharing virtual class LogFinalizer implements System.Finalizer {
/**
* @description Is called by any `System.Queueable` where the finalizer is attached after the Queueable's `execute` method finishes
* @param fc The `System.FinalizerContext` associated with the finalizer
*/
public void execute(System.FinalizerContext fc) {
switch on fc.getResult() {
when UNHANDLED_EXCEPTION {
Logger.error('There was an error during this queueable job');
Logger.error('Error details', fc.getException());
}
}
this.innerExecute(fc);
Logger.saveLog();
}

/**
* @description Subscribers can optionally override this method with their own implementation to do further processing/re-queueing
* @param fc The `System.FinalizerContext` associated with the finalizer
*/
@SuppressWarnings('PMD.EmptyStatementBlock')
protected virtual void innerExecute(System.FinalizerContext fc) {
// subscribers can override this to do their own post-processing if necessary, otherwise it's a no-op
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<status>Active</status>
</ApexClass>
Loading

0 comments on commit 520a385

Please sign in to comment.