From 520a3852554723df0f4eb85e557304e27a3b1fd3 Mon Sep 17 00:00:00 2001 From: James Simone <16430727+jamessimone@users.noreply.github.com> Date: Mon, 9 May 2022 13:11:20 -0600 Subject: [PATCH] Adding new plugin to add additional logging options for async failures (#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 --- README.md | 4 +- ...ger SObject Handler Layout.layout-meta.xml | 4 + .../SObjectTypeOverride__c.field-meta.xml | 15 +++ .../fields/SObjectType__c.field-meta.xml | 2 +- .../classes/LoggerSObjectHandler.cls | 15 ++- .../classes/LoggerSObjectHandler_Tests.cls | 20 ++++ ...to-batch-logging-with-logger-parameter.png | Bin 0 -> 26889 bytes .../plugins/async-failure-additions/README.md | 52 +++++++++ .../classes/LogBatchApexErrorEventHandler.cls | 67 ++++++++++++ ...LogBatchApexErrorEventHandler.cls-meta.xml | 5 + .../LogBatchApexErrorEventHandler_Tests.cls | 100 ++++++++++++++++++ ...chApexErrorEventHandler_Tests.cls-meta.xml | 5 + .../plugin/classes/LogFinalizer.cls | 33 ++++++ .../plugin/classes/LogFinalizer.cls-meta.xml | 5 + .../plugin/classes/LogFinalizer_Tests.cls | 55 ++++++++++ .../classes/LogFinalizer_Tests.cls-meta.xml | 5 + .../LogBatchApexErrorEventTrigger.trigger | 7 ++ ...atchApexErrorEventTrigger.trigger-meta.xml | 5 + sfdx-project.json | 10 +- 19 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectTypeOverride__c.field-meta.xml create mode 100644 nebula-logger/plugins/async-failure-additions/.images/opt-into-batch-logging-with-logger-parameter.png create mode 100644 nebula-logger/plugins/async-failure-additions/README.md create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls-meta.xml create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls-meta.xml create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls-meta.xml create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls-meta.xml create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger create mode 100644 nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger-meta.xml diff --git a/README.md b/README.md index 7d64467af..be2af6b7e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nebula-logger/core/main/configuration/layouts/LoggerSObjectHandler__mdt-Logger SObject Handler Layout.layout-meta.xml b/nebula-logger/core/main/configuration/layouts/LoggerSObjectHandler__mdt-Logger SObject Handler Layout.layout-meta.xml index 055e5a5c6..d274e2a70 100644 --- a/nebula-logger/core/main/configuration/layouts/LoggerSObjectHandler__mdt-Logger SObject Handler Layout.layout-meta.xml +++ b/nebula-logger/core/main/configuration/layouts/LoggerSObjectHandler__mdt-Logger SObject Handler Layout.layout-meta.xml @@ -18,6 +18,10 @@ Required SObjectHandlerApexClass__c + + Edit + SObjectTypeOverride__c + diff --git a/nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectTypeOverride__c.field-meta.xml b/nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectTypeOverride__c.field-meta.xml new file mode 100644 index 000000000..fc048f435 --- /dev/null +++ b/nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectTypeOverride__c.field-meta.xml @@ -0,0 +1,15 @@ + + + SObjectTypeOverride__c + 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. + false + SubscriberControlled + 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. + + 255 + false + Text + false + diff --git a/nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectType__c.field-meta.xml b/nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectType__c.field-meta.xml index 2bcd98bfb..80c5bd339 100644 --- a/nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectType__c.field-meta.xml +++ b/nebula-logger/core/main/configuration/objects/LoggerSObjectHandler__mdt/fields/SObjectType__c.field-meta.xml @@ -7,7 +7,7 @@ EntityDefinition Logger SObject Handler Configurations LoggerSObjectHandlerConfigurations - true + false MetadataRelationship true diff --git a/nebula-logger/core/main/logger-engine/classes/LoggerSObjectHandler.cls b/nebula-logger/core/main/logger-engine/classes/LoggerSObjectHandler.cls index 8eef8285c..640ca683e 100644 --- a/nebula-logger/core/main/logger-engine/classes/LoggerSObjectHandler.cls +++ b/nebula-logger/core/main/logger-engine/classes/LoggerSObjectHandler.cls @@ -250,12 +250,12 @@ public without sharing abstract class LoggerSObjectHandler { private static Map queryHandlerConfigurations() { Map sobjectTypeToHandlerConfiguration = new Map(); 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); } @@ -266,6 +266,14 @@ 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); @@ -273,8 +281,7 @@ public without sharing abstract class LoggerSObjectHandler { @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); } diff --git a/nebula-logger/core/tests/logger-engine/classes/LoggerSObjectHandler_Tests.cls b/nebula-logger/core/tests/logger-engine/classes/LoggerSObjectHandler_Tests.cls index 454bd7aa9..cccd481be 100644 --- a/nebula-logger/core/tests/logger-engine/classes/LoggerSObjectHandler_Tests.cls +++ b/nebula-logger/core/tests/logger-engine/classes/LoggerSObjectHandler_Tests.cls @@ -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(); diff --git a/nebula-logger/plugins/async-failure-additions/.images/opt-into-batch-logging-with-logger-parameter.png b/nebula-logger/plugins/async-failure-additions/.images/opt-into-batch-logging-with-logger-parameter.png new file mode 100644 index 0000000000000000000000000000000000000000..c0291d4b4e61be1de5feaa662fbae091df86f1b4 GIT binary patch literal 26889 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 @c!9)pLz##|Y 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 :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, 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 + } +} +``` diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls new file mode 100644 index 000000000..4fd27b73f --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls @@ -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 batchApexErrorEvents; + + /** + * @description Opts into the default constructor + */ + public LogBatchApexErrorEventHandler() { + super(); + } + + public override Schema.SObjectType getSObjectType() { + return Schema.BatchApexErrorEvent.SObjectType; + } + + protected override void executeAfterInsert(List triggerNew) { + this.batchApexErrorEvents = (List) triggerNew; + this.handleJobErrors(); + } + + private void handleJobErrors() { + Set asyncApexJobIds = new Set(); + for (BatchApexErrorEvent evt : this.batchApexErrorEvents) { + asyncApexJobIds.add(evt.AsyncApexJobId); + } + + Map jobIdToClass = new Map([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{ errorEvent.AsyncApexJobId, errorEvent.ExceptionType, errorEvent.Message, errorEvent.Phase, errorEvent.StackTrace } + ); + Logger.error(logMessage); + } + if (shouldSaveLogs) { + Logger.saveLog(); + } + } + + private Boolean getShouldSaveLogs(Map jobIdToClass, BatchApexErrorEvent errorEvent) { + if (shouldSaveLogs == false) { + AsyncApexJob job = jobIdToClass.get(errorEvent.AsyncApexJobId); + List configurationList = LoggerParameter.matchOnPrefix(BATCH_ERROR_LOGGER); + for (LoggerParameter__mdt config : configurationList) { + if (config.Value__c == job?.ApexClass.Name) { + shouldSaveLogs = true; + break; + } + } + } + return shouldSaveLogs; + } +} diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls-meta.xml b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls-meta.xml new file mode 100644 index 000000000..891916bb0 --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls new file mode 100644 index 000000000..e327d41dd --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls @@ -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, 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{ '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 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]; + } +} diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls-meta.xml b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls-meta.xml new file mode 100644 index 000000000..891916bb0 --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogBatchApexErrorEventHandler_Tests.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls new file mode 100644 index 000000000..8e29533f7 --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls @@ -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 + } +} diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls-meta.xml b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls-meta.xml new file mode 100644 index 000000000..891916bb0 --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls new file mode 100644 index 000000000..fc4a56f8d --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls @@ -0,0 +1,55 @@ +//------------------------------------------------------------------------------------------------// +// 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') +@IsTest(IsParallel=true) +private class LogFinalizer_Tests { + private static final String EXPECTED_ERROR_MESSAGE = 'Gack'; + @IsTest + static void it_should_not_log_on_queueable_success() { + System.Test.startTest(); + System.enqueueJob(new ExampleQueueable()); + System.Test.stopTest(); + System.Test.getEventBus().deliver(); + + System.assertEquals(0, [SELECT COUNT() FROM Log__c], 'Should not log if no errors'); + } + + @IsTest + static void it_should_log_on_queueable_error() { + try { + System.Test.startTest(); + System.enqueueJob(new ExampleFailedQueueable()); + System.Test.stopTest(); + } catch (Exception ex) { + System.assertEquals(EXPECTED_ERROR_MESSAGE, ex.getMessage()); + } + System.Test.getEventBus().deliver(); + + List logEntries = [SELECT Message__c, ExceptionMessage__c FROM LogEntry__c]; + System.assertEquals(2, logEntries.size(), 'Should log for errors'); + LogEntry__c firstEntry = logEntries.get(0); + System.assertEquals('There was an error during this queueable job', firstEntry.Message__c); + LogEntry__c secondEntry = logEntries.get(1); + System.assertEquals('Error details', secondEntry.Message__c); + System.assertEquals(EXPECTED_ERROR_MESSAGE, secondEntry.ExceptionMessage__c); + } + + private virtual class ExampleQueueable implements System.Queueable { + public void execute(System.QueueableContext qc) { + System.attachFinalizer(new LogFinalizer()); + this.innerExecute(); + } + + @SuppressWarnings('PMD.EmptyStatementBlock') + protected virtual void innerExecute() { + } + } + + private virtual class ExampleFailedQueueable extends ExampleQueueable { + protected override void innerExecute() { + throw new IllegalArgumentException(EXPECTED_ERROR_MESSAGE); + } + } +} diff --git a/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls-meta.xml b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls-meta.xml new file mode 100644 index 000000000..891916bb0 --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/classes/LogFinalizer_Tests.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger b/nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger new file mode 100644 index 000000000..8c68f608e --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger @@ -0,0 +1,7 @@ +//------------------------------------------------------------------------------------------------// +// 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. // +//------------------------------------------------------------------------------------------------// +trigger LogBatchApexErrorEventTrigger on BatchApexErrorEvent(after insert) { + LoggerSObjectHandler.getHandler(Schema.BatchApexErrorEvent.SObjectType, new LogBatchApexErrorEventHandler()).execute(); +} diff --git a/nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger-meta.xml b/nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger-meta.xml new file mode 100644 index 000000000..5e573d796 --- /dev/null +++ b/nebula-logger/plugins/async-failure-additions/plugin/triggers/LogBatchApexErrorEventTrigger.trigger-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/sfdx-project.json b/sfdx-project.json index 2c3997710..3e21e340f 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -65,6 +65,14 @@ "versionDescription": "TODO", "default": false }, + { + "default": false, + "package": "Nebula Logger - Plugin - Async Failure Additions", + "path": "./nebula-logger/plugins/async-failure-additions/plugin", + "versionName": "Initial release with Finalizer and BatchApexErrorEvent functionality", + "versionNumber": "0.0.1.NEXT", + "versionDescription": "Adds the capability to get Logs created for unexpected Apex Batch class failures through LoggerParameter__mdt additions, and provides a Finalizer implementation for logging Queueable failures" + }, { "path": "./nebula-logger/extra-tests", "default": false @@ -104,7 +112,7 @@ "Nebula Logger - Core@4.6.16-0-ui-cleanup": "04t5Y0000015lLzQAI", "Nebula Logger - Core@4.7.0-25-spring-'22-release": "04t5Y0000015lXSQAY", "Nebula Logger - Core@4.7.1-8-plugin-framework-overhaul": "04t5Y0000015lgBQAQ", - "Nebula Logger - Core@4.7.2-NEXT-parent-log-transaction-id-bugfix": "04t5Y0000015lh4QAA", + "Nebula Logger - Core@4.7.2-NEXT-parent-log-transaction-id-bugfix": "04t5Y0000015lhEQAQ", "Nebula Logger - Plugin - Big Object Archiving": "0Ho5Y000000blMSSAY", "Nebula Logger - Plugin - Big Object Archiving@0.9.0-2": "04t5Y0000015lgLQAQ", "Nebula Logger - Plugin - Log Retention Rules": "0Ho5Y000000blNfSAI",