From a9409acdae8db98d943e1cdc834a38f3c91192b8 Mon Sep 17 00:00:00 2001 From: Jonathan Gillespie Date: Mon, 21 Jun 2021 18:59:31 -0700 Subject: [PATCH] Added the ability to log Flow record collections (#171) * Refactored FlowLogEntry and FlowRecordLogEntry to use new FlowLogger class for shared logic * Added FlowCollectionLogEntry class * Updated docs * Small bugfix for dealing with List & SObjectType in LogEntryEventBuilder * Added a bit more Flow details to README.md * Bumped package version & promoted new package version --- .github/workflows/deploy.yml | 19 ++ README.md | 17 +- ...flow-builder-logging-invocable-actions.png | Bin 0 -> 28197 bytes docs/index.md | 8 + docs/logger-engine/FlowCollectionLogEntry.md | 79 +++++++ docs/logger-engine/FlowLogEntry.md | 6 + docs/logger-engine/FlowLogger.md | 57 +++++ docs/logger-engine/FlowRecordLogEntry.md | 6 + .../log-management/classes/LogHandler.cls | 6 +- .../classes/LogMassDeleteExtension.cls | 6 +- .../LoggerAdmin.permissionset-meta.xml | 8 + .../LoggerEndUser.permissionset-meta.xml | 8 + .../profiles/Admin.profile-meta.xml | 16 ++ .../classes/FlowCollectionLogEntry.cls | 81 +++++++ .../FlowCollectionLogEntry.cls-meta.xml | 5 + .../logger-engine/classes/FlowLogEntry.cls | 57 +---- .../main/logger-engine/classes/FlowLogger.cls | 97 +++++++++ .../classes/FlowLogger.cls-meta.xml | 5 + .../classes/FlowRecordLogEntry.cls | 55 +---- .../classes/LogEntryEventBuilder.cls | 15 +- .../LoggerLogCreator.permissionset-meta.xml | 8 + .../classes/LogMassDeleteExtension_Tests.cls | 33 +++ .../classes/FlowCollectionLogEntry_Tests.cls | 200 ++++++++++++++++++ .../FlowCollectionLogEntry_Tests.cls-meta.xml | 5 + .../classes/FlowLogEntry_Tests.cls | 2 - .../classes/FlowLogger_Tests.cls | 60 ++++++ .../classes/FlowLogger_Tests.cls-meta.xml | 5 + .../classes/FlowRecordLogEntry_Tests.cls | 2 - package.json | 2 +- scripts/generate-docs.ps1 | 2 +- sfdx-project.json | 7 +- 31 files changed, 755 insertions(+), 122 deletions(-) create mode 100644 content/flow-builder-logging-invocable-actions.png create mode 100644 docs/logger-engine/FlowCollectionLogEntry.md create mode 100644 docs/logger-engine/FlowLogger.md create mode 100644 nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls create mode 100644 nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls-meta.xml create mode 100644 nebula-logger/main/logger-engine/classes/FlowLogger.cls create mode 100644 nebula-logger/main/logger-engine/classes/FlowLogger.cls-meta.xml create mode 100644 nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls create mode 100644 nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls-meta.xml create mode 100644 nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls create mode 100644 nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls-meta.xml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1434f46bb..498c79583 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,25 @@ name: Deployment on: push: + branches: + - main + paths-ignore: + - 'content/**' + - 'docs/**' + - 'examples/**' + - 'packages/**' + - '.forceignore' + - '.gitignore' + - '.prettierignore' + - '.prettierrc' + - 'Contributing.md' + - 'LICENSE' + - 'package.json' + - 'README.md' + - './**/README.md' + - 'sfdx-project.json' + pull_request: + types: [opened, edited, synchronize, reopened] paths-ignore: - 'content/**' - 'docs/**' diff --git a/README.md b/README.md index 826656390..a4a4981eb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Designed for Salesforce admins, developers & architects. A robust logger for Apex, Flow, Process Builder & Integrations. -[![Install Unlocked Package](./content/btn-install-unlocked-package.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000027FMrQAM) +[![Install Unlocked Package](./content/btn-install-unlocked-package.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000027FN6QAM) [![Install Managed Package](./content/btn-install-managed-package.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000027FMhQAM) [![Deploy Unpackaged Metadata](./content/btn-deploy-unpackaged-metadata.png)](https://githubsfdeploy.herokuapp.com/?owner=jongpie&repo=NebulaLogger&ref=main) [![View Documentation](./content/btn-view-documentation.png)](https://jongpie.github.io/NebulaLogger/) @@ -174,7 +174,7 @@ This generates 1 consolidated `Log__c`, containing `LogEntry__c` records from bo --- -## Advanced Features for Apex Developers +## Features for Apex Developers Within Apex, there are several different methods that you can use that provide greater control over the logging system. @@ -348,6 +348,19 @@ For more details, check out the `LogMessage` class [documentation](https://jongp --- +## Features for Flow Builders + +Within Flow (and Process Builder), there are 4 invocable actions that you can use to leverage Nebula Logger + +1. 'Add Log Entry' - uses the class `FlowLogEntry` to add a log entry with a specified message +2. 'Add Log Entry for an SObject Record' - uses the class `FlowRecordLogEntry` to add a log entry with a specified message for a particular SObject record +3. 'Add Log Entry for an SObject Record Collection' - uses the class `FlowCollectionLogEntry` to add a log entry with a specified message for an SObject record collection +4. 'Save Log' - uses the class `Logger` to save any pending logs + +![Flow Builder: Logging Invocable Actions](./content/flow-builder-logging-invocable-actions.png) + +--- + ## Log Management ### Logger Console App diff --git a/content/flow-builder-logging-invocable-actions.png b/content/flow-builder-logging-invocable-actions.png new file mode 100644 index 0000000000000000000000000000000000000000..59f05bf9053c298611a098abd4c91520d4c827e2 GIT binary patch literal 28197 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 literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 0309ae72e..979d4f305 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,10 +6,18 @@ layout: default ## Logger Engine +### [FlowCollectionLogEntry](logger-engine/FlowCollectionLogEntry) + +Handles adding new log entries in Flow for a particular `SObject` record collection + ### [FlowLogEntry](logger-engine/FlowLogEntry) Handles adding new log entries in Flow +### [FlowLogger](logger-engine/FlowLogger) + +Handles some common logic used by `FlowLogEntry`, `FlowRecordLogEntry` and `FlowCollectionLogEntry` + ### [FlowRecordLogEntry](logger-engine/FlowRecordLogEntry) Handles adding new log entries in Flow for a particular `SObject` record diff --git a/docs/logger-engine/FlowCollectionLogEntry.md b/docs/logger-engine/FlowCollectionLogEntry.md new file mode 100644 index 000000000..2d32504ec --- /dev/null +++ b/docs/logger-engine/FlowCollectionLogEntry.md @@ -0,0 +1,79 @@ +--- +layout: default +--- + +## FlowCollectionLogEntry class + +Handles adding new log entries in Flow for a particular `SObject` record collection + +### Related + +[FlowLogEntry](FlowLogEntry) + +[FlowRecordLogEntry](FlowRecordLogEntry) + +[FlowLogger](FlowLogger) + +[Logger](Logger) + +[LogEntryEventBuilder](LogEntryEventBuilder) + +--- + +### Properties + +#### `faultMessage` → `String` + +Optionally log a Flow fault error message + +#### `flowName` → `String` + +The name of the Flow creating the log entry. Due to Salesforce limitations, this cannot be automatically determined + +#### `loggingLevelName` → `String` + +Optionally specify a logging level - the default is 'DEBUG' + +#### `message` → `String` + +The message to log + +#### `records` → `List` + +The records to relate to this log entry - the records' JSON is automatically added to the log entry + +#### `saveLog` → `Boolean` + +Optionally choose to save any pending log entries + +#### `timestamp` → `DateTime` + +#### `topics` → `List` + +Optionally provide a list of topics to dynamically assign to the log entry + +--- + +### Methods + +#### `addFlowCollectionEntries(List flowCollectionLogEntries)` → `List` + +addFlowRecordEntries description + +##### Parameters + +| Param | Description | +| ---------------------- | ------------------------------------------------ | +| `flowRecordLogEntries` | The list of FlowRecordLogEntry instances to save | + +##### Return + +**Type** + +List + +**Description** + +The current transaction's ID (based on `Logger.getTransactionId()`) + +--- diff --git a/docs/logger-engine/FlowLogEntry.md b/docs/logger-engine/FlowLogEntry.md index dddf20488..d0013d71e 100644 --- a/docs/logger-engine/FlowLogEntry.md +++ b/docs/logger-engine/FlowLogEntry.md @@ -10,6 +10,10 @@ Handles adding new log entries in Flow [FlowRecordLogEntry](FlowRecordLogEntry) +[FlowCollectionLogEntry](FlowCollectionLogEntry) + +[FlowLogger](FlowLogger) + [Logger](Logger) [LogEntryEventBuilder](LogEntryEventBuilder) @@ -42,6 +46,8 @@ Optionally relate the log entry to a particular record ID Optionally choose to save any pending log entries. +#### `timestamp` → `DateTime` + #### `topics` → `List` Optionally provide a list of topics to dynamically assign to the log entry diff --git a/docs/logger-engine/FlowLogger.md b/docs/logger-engine/FlowLogger.md new file mode 100644 index 000000000..9c2e274c2 --- /dev/null +++ b/docs/logger-engine/FlowLogger.md @@ -0,0 +1,57 @@ +--- +layout: default +--- + +## FlowLogger class + +Handles some common logic used by `FlowLogEntry`, `FlowRecordLogEntry` and `FlowCollectionLogEntry` + +### Related + +[FlowLogEntry](FlowLogEntry) + +[FlowRecordLogEntry](FlowRecordLogEntry) + +[FlowCollectionLogEntry](FlowCollectionLogEntry) + +[Logger](Logger) + +[LogEntryEventBuilder](LogEntryEventBuilder) + +--- + +### Methods + +#### `addEntries(List flowEntries)` → `List` + +--- + +### Inner Classes + +#### FlowLogger.LogEntry class + +--- + +##### Properties + +###### `faultMessage` → `String` + +###### `flowName` → `String` + +###### `loggingLevelName` → `String` + +###### `message` → `String` + +###### `saveLog` → `Boolean` + +###### `timestamp` → `DateTime` + +###### `topics` → `List` + +--- + +##### Methods + +###### `addToLoggerBuffer()` → `LogEntryEventBuilder` + +--- diff --git a/docs/logger-engine/FlowRecordLogEntry.md b/docs/logger-engine/FlowRecordLogEntry.md index a49d764b7..c8f2f3952 100644 --- a/docs/logger-engine/FlowRecordLogEntry.md +++ b/docs/logger-engine/FlowRecordLogEntry.md @@ -10,6 +10,10 @@ Handles adding new log entries in Flow for a particular `SObject` record [FlowLogEntry](FlowLogEntry) +[FlowCollectionLogEntry](FlowCollectionLogEntry) + +[FlowLogger](FlowLogger) + [Logger](Logger) [LogEntryEventBuilder](LogEntryEventBuilder) @@ -42,6 +46,8 @@ The record to relate to this log entry - the record's JSON is automatically adde Optionally choose to save any pending log entries +#### `timestamp` → `DateTime` + #### `topics` → `List` Optionally provide a list of topics to dynamically assign to the log entry diff --git a/nebula-logger/main/log-management/classes/LogHandler.cls b/nebula-logger/main/log-management/classes/LogHandler.cls index e697b2075..42ce37ffa 100644 --- a/nebula-logger/main/log-management/classes/LogHandler.cls +++ b/nebula-logger/main/log-management/classes/LogHandler.cls @@ -241,13 +241,11 @@ public without sharing class LogHandler extends LoggerSObjectHandler { releaseCycle = 'Non-Preview Instance'; } else if (previewInstances.contains(ORGANIZATION.InstanceName)) { releaseCycle = 'Preview Instance'; - } else { - // Use 'Unknown' as the default for private instances and situations where the hardcoded sets above are missing some values - releaseCycle = 'Unknown'; } for (Log__c log : this.logs) { - log.OrganizationInstanceReleaseCycle__c = releaseCycle; + // Use 'Unknown' as the default for private instances and situations where the hardcoded sets above are missing some values + log.OrganizationInstanceReleaseCycle__c = String.isNotBlank(releaseCycle) ? releaseCycle : 'Unknown'; } } diff --git a/nebula-logger/main/log-management/classes/LogMassDeleteExtension.cls b/nebula-logger/main/log-management/classes/LogMassDeleteExtension.cls index 71eaf3fc8..2c7a350f3 100644 --- a/nebula-logger/main/log-management/classes/LogMassDeleteExtension.cls +++ b/nebula-logger/main/log-management/classes/LogMassDeleteExtension.cls @@ -55,11 +55,7 @@ public with sharing class LogMassDeleteExtension { * @return The PageReference of the previous page (based on `controller.cancel()`) */ public PageReference deleteSelectedLogs() { - try { - delete getDeletableLogs(); - } catch (Exception ex) { - ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, ex.getMessage())); - } + delete getDeletableLogs(); // The controller's method cancel() just returns the user to the previous page - it doesn't rollback any DML statements (like the delete above) return this.controller.cancel(); diff --git a/nebula-logger/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml b/nebula-logger/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml index 941ff8ddf..0223770c8 100644 --- a/nebula-logger/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml +++ b/nebula-logger/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml @@ -4,10 +4,18 @@ LoggerConsole true + + FlowCollectionLogEntry + true + FlowLogEntry true + + FlowLogger + true + FlowRecordLogEntry true diff --git a/nebula-logger/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml b/nebula-logger/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml index 4d097b02a..c59a8ed95 100644 --- a/nebula-logger/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml +++ b/nebula-logger/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml @@ -1,9 +1,17 @@ + + FlowCollectionLogEntry + true + FlowLogEntry true + + FlowLogger + true + FlowRecordLogEntry true diff --git a/nebula-logger/main/log-management/profiles/Admin.profile-meta.xml b/nebula-logger/main/log-management/profiles/Admin.profile-meta.xml index 01a9639c9..38e88f8f9 100644 --- a/nebula-logger/main/log-management/profiles/Admin.profile-meta.xml +++ b/nebula-logger/main/log-management/profiles/Admin.profile-meta.xml @@ -5,6 +5,14 @@ false true + + FlowCollectionLogEntry + true + + + FlowCollectionLogEntry_Tests + true + FlowLogEntry true @@ -13,6 +21,14 @@ FlowLogEntry_Tests true + + FlowLogger + true + + + FlowLogger_Tests + true + FlowRecordLogEntry true diff --git a/nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls b/nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls new file mode 100644 index 000000000..8ee1c5d09 --- /dev/null +++ b/nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls @@ -0,0 +1,81 @@ +//------------------------------------------------------------------------------------------------// +// 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 Logger Engine + * @description Handles adding new log entries in Flow for a particular `SObject` record collection + * @see FlowLogEntry + * @see FlowRecordLogEntry + * @see FlowLogger + * @see Logger + * @see LogEntryEventBuilder + */ +global inherited sharing class FlowCollectionLogEntry { + /** + * @description The name of the Flow creating the log entry. + * Due to Salesforce limitations, this cannot be automatically determined + */ + @InvocableVariable(required=true label='Flow or Process Builder Name') + global String flowName; + + /** + * @description The message to log + */ + @InvocableVariable(required=true label='Log Entry Message') + global String message; + + /** + * @description The records to relate to this log entry - the records' JSON is automatically added to the log entry + */ + @InvocableVariable(required=true label='Records') + global List records; + + /** + * @description Optionally log a Flow fault error message + */ + @InvocableVariable(required=false label='(Optional) Flow Fault Error Message') + global String faultMessage; + /** + * @description Optionally choose to save any pending log entries + */ + @InvocableVariable(required=false label='(Optional) Save Log') + global Boolean saveLog = false; + + /** + * @description Optionally specify a logging level - the default is 'DEBUG' + */ + @InvocableVariable(required=false label='(Optional) Logging Level') + global String loggingLevelName; + + /** + * @description Optionally provide a list of topics to dynamically assign to the log entry + */ + @InvocableVariable(required=false label='(Optional) Topics') + public List topics; + + public DateTime timestamp = System.now(); + + /** + * addFlowRecordEntries description + * @param flowRecordLogEntries The list of FlowRecordLogEntry instances to save + * @return The current transaction's ID (based on `Logger.getTransactionId()`) + */ + @InvocableMethod( + category='Logging' + label='Add Log Entry for an SObject Record Collection' + description='Creates a log entry for a flow or process builder and stores the record as JSON' + ) + global static List addFlowCollectionEntries(List flowCollectionLogEntries) { + List shadowLogEntries = new List(); + for (FlowCollectionLogEntry flowCollectionLogEntry : flowCollectionLogEntries) { + FlowLogger.LogEntry shadowLogEntry = (FlowLogger.LogEntry) JSON.deserialize(JSON.serialize(flowCollectionLogEntry), FlowLogger.LogEntry.class); + shadowLogEntry.addToLoggerBuffer()?.setRecord(flowCollectionLogEntry.records); + + shadowLogEntries.add(shadowLogEntry); + } + + return FlowLogger.addEntries(shadowLogEntries); + } +} diff --git a/nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls-meta.xml b/nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls-meta.xml new file mode 100644 index 000000000..482559c8b --- /dev/null +++ b/nebula-logger/main/logger-engine/classes/FlowCollectionLogEntry.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/nebula-logger/main/logger-engine/classes/FlowLogEntry.cls b/nebula-logger/main/logger-engine/classes/FlowLogEntry.cls index 9f2cde893..b36cccc91 100644 --- a/nebula-logger/main/logger-engine/classes/FlowLogEntry.cls +++ b/nebula-logger/main/logger-engine/classes/FlowLogEntry.cls @@ -7,6 +7,8 @@ * @group Logger Engine * @description Handles adding new log entries in Flow * @see FlowRecordLogEntry + * @see FlowCollectionLogEntry + * @see FlowLogger * @see Logger * @see LogEntryEventBuilder */ @@ -34,7 +36,7 @@ global inherited sharing class FlowLogEntry { * @description Optionally choose to save any pending log entries. */ @InvocableVariable(required=false label='(Optional) Save Log') - global Boolean saveLog = false; + global Boolean saveLog; /** * @description Optionally relate the log entry to a particular record ID @@ -54,7 +56,7 @@ global inherited sharing class FlowLogEntry { @InvocableVariable(required=false label='(Optional) Topics') public List topics; - private DateTime timestamp = System.now(); + public DateTime timestamp = System.now(); /** * addFlowEntries description @@ -63,55 +65,14 @@ global inherited sharing class FlowLogEntry { */ @InvocableMethod(category='Logging' label='Add Log Entry' description='Creates a log entry for a flow or process builder') global static List addFlowEntries(List flowLogEntries) { - Boolean saveLog = false; + List shadowLogEntries = new List(); for (FlowLogEntry flowLogEntry : flowLogEntries) { - // Set the logging level if it's blank - if (String.isBlank(flowLogEntry.loggingLevelName)) { - if (String.isNotBlank(flowLogEntry.faultMessage)) { - flowLogEntry.loggingLevelName = 'ERROR'; - } else { - flowLogEntry.loggingLevelName = 'DEBUG'; - } - } + FlowLogger.LogEntry shadowLogEntry = (FlowLogger.LogEntry) JSON.deserialize(JSON.serialize(flowLogEntry), FlowLogger.LogEntry.class); + shadowLogEntry.addToLoggerBuffer()?.setRecordId(flowLogEntry.recordId); - LoggingLevel loggingLevel = Logger.getLoggingLevel(flowLogEntry.loggingLevelName); - - LogEntryEventBuilder logEntryEventBuilder = Logger.newEntry(loggingLevel, flowLogEntry.message) - .setRecordId(flowLogEntry.recordId) - .setTopics(flowLogEntry.topics); - - LogEntryEvent__e logEntryEvent = logEntryEventBuilder.getLogEntryEvent(); - - if (logEntryEventBuilder.shouldSave() == false) { - continue; - } - - logEntryEvent.OriginLocation__c = flowLogEntry.flowName; - logEntryEvent.OriginType__c = 'Flow'; - logEntryEvent.Timestamp__c = flowLogEntry.timestamp; - - if (String.isNotBlank(flowLogEntry.faultMessage)) { - logEntryEvent.ExceptionMessage__c = flowLogEntry.faultMessage; - logEntryEvent.ExceptionType__c = 'Flow.FaultError'; - } - - if (flowLogEntry.saveLog == true) { - saveLog = flowLogEntry.saveLog; - } - } - - if (saveLog == true) { - Logger.saveLog(); + shadowLogEntries.add(shadowLogEntry); } - // Event though it's the same transaction ID, Salesforce expects the returned list... - // ...to have the same number of items as the initial input. - // When there's a mismatch, Salesforce throws an error: - // FLOW_ELEMENT_ERROR The number of results does not match the number of interviews that were executed in a single bulk execution request.|FlowActionCall - List transactionIds = new List(); - for (Integer i = 0; i < flowLogEntries.size(); i++) { - transactionIds.add(Logger.getTransactionId()); - } - return transactionIds; + return FlowLogger.addEntries(shadowLogEntries); } } diff --git a/nebula-logger/main/logger-engine/classes/FlowLogger.cls b/nebula-logger/main/logger-engine/classes/FlowLogger.cls new file mode 100644 index 000000000..2e4ded7d1 --- /dev/null +++ b/nebula-logger/main/logger-engine/classes/FlowLogger.cls @@ -0,0 +1,97 @@ +//------------------------------------------------------------------------------------------------// +// 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 Logger Engine + * @description Handles some common logic used by `FlowLogEntry`, `FlowRecordLogEntry` and `FlowCollectionLogEntry` + * @see FlowLogEntry + * @see FlowRecordLogEntry + * @see FlowCollectionLogEntry + * @see Logger + * @see LogEntryEventBuilder + */ +public inherited sharing class FlowLogger { + // Invocable methods and properties are goofy, but we can be goofier + // Properties goofiness: you can’t keep common properties in a parent class & extend properties in other classes, + // which also causes issues with sharing logic between classes (via abstract and virtual classes) + // +3 goofiness points: if we duplicate properties between classes, we can convert all other classes + // to one common class (FlowLogger.LogEntry), and use it for shared logic. + // For maximum goofiness, the conversion between classes happens using JSON.deserialize() + public class LogEntry { + // Public member variables - all other Flow classes should duplicate these public variables + public String flowName; + public String message; + public String faultMessage; + public String loggingLevelName; + public List topics; + public Boolean saveLog = false; + public DateTime timestamp; + + // Private member variables + private LoggingLevel loggingLevel; + private LogEntryEventBuilder logEntryEventBuilder; + private LogEntryEvent__e logEntryEvent; + + public LogEntryEventBuilder addToLoggerBuffer() { + if (this.logEntryEventBuilder != null) { + return this.logEntryEventBuilder; + } + + // Set the logging level if it's blank + if (String.isBlank(this.loggingLevelName)) { + if (String.isNotBlank(this.faultMessage)) { + this.loggingLevelName = 'ERROR'; + } else { + this.loggingLevelName = 'DEBUG'; + } + } + + this.loggingLevel = Logger.getLoggingLevel(this.loggingLevelName); + this.logEntryEventBuilder = Logger.newEntry(this.loggingLevel, this.message).setTopics(this.topics); + this.logEntryEvent = logEntryEventBuilder.getLogEntryEvent(); + + if (this.logEntryEventBuilder.shouldSave() == false) { + return this.logEntryEventBuilder; + } + + this.logEntryEvent.OriginLocation__c = this.flowName; + this.logEntryEvent.OriginType__c = 'Flow'; + this.logEntryEvent.Timestamp__c = this.timestamp; + + if (String.isNotBlank(this.faultMessage)) { + this.logEntryEvent.ExceptionMessage__c = this.faultMessage; + this.logEntryEvent.ExceptionType__c = 'Flow.FaultError'; + } + + return this.logEntryEventBuilder; + } + } + + // Static methods + public static List addEntries(List flowEntries) { + Boolean saveLog = false; + for (LogEntry flowEntry : flowEntries) { + flowEntry.addToLoggerBuffer(); + + if (flowEntry.saveLog == true) { + saveLog = flowEntry.saveLog; + } + } + + if (saveLog == true) { + Logger.saveLog(); + } + + // Event though it's the same transaction ID, Salesforce expects the returned list... + // ...to have the same number of items as the initial input. + // When there's a mismatch, Salesforce throws an error: + // FLOW_ELEMENT_ERROR The number of results does not match the number of interviews that were executed in a single bulk execution request.|FlowActionCall + List transactionIds = new List(); + for (Integer i = 0; i < flowEntries.size(); i++) { + transactionIds.add(Logger.getTransactionId()); + } + return transactionIds; + } +} diff --git a/nebula-logger/main/logger-engine/classes/FlowLogger.cls-meta.xml b/nebula-logger/main/logger-engine/classes/FlowLogger.cls-meta.xml new file mode 100644 index 000000000..482559c8b --- /dev/null +++ b/nebula-logger/main/logger-engine/classes/FlowLogger.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/nebula-logger/main/logger-engine/classes/FlowRecordLogEntry.cls b/nebula-logger/main/logger-engine/classes/FlowRecordLogEntry.cls index f6fa63ee8..ec44957a8 100644 --- a/nebula-logger/main/logger-engine/classes/FlowRecordLogEntry.cls +++ b/nebula-logger/main/logger-engine/classes/FlowRecordLogEntry.cls @@ -7,6 +7,8 @@ * @group Logger Engine * @description Handles adding new log entries in Flow for a particular `SObject` record * @see FlowLogEntry + * @see FlowCollectionLogEntry + * @see FlowLogger * @see Logger * @see LogEntryEventBuilder */ @@ -53,7 +55,7 @@ global inherited sharing class FlowRecordLogEntry { @InvocableVariable(required=false label='(Optional) Topics') public List topics; - private DateTime timestamp = System.now(); + public DateTime timestamp = System.now(); /** * addFlowRecordEntries description @@ -66,55 +68,14 @@ global inherited sharing class FlowRecordLogEntry { description='Creates a log entry for a flow or process builder and stores the record as JSON' ) global static List addFlowRecordEntries(List flowRecordLogEntries) { - Boolean saveLog = false; + List shadowLogEntries = new List(); for (FlowRecordLogEntry flowRecordLogEntry : flowRecordLogEntries) { - // Set the logging level if it's blank - if (String.isBlank(flowRecordLogEntry.loggingLevelName)) { - if (String.isNotBlank(flowRecordLogEntry.faultMessage)) { - flowRecordLogEntry.loggingLevelName = 'ERROR'; - } else { - flowRecordLogEntry.loggingLevelName = 'DEBUG'; - } - } + FlowLogger.LogEntry shadowLogEntry = (FlowLogger.LogEntry) JSON.deserialize(JSON.serialize(flowRecordLogEntry), FlowLogger.LogEntry.class); + shadowLogEntry.addToLoggerBuffer()?.setRecord(flowRecordLogEntry.record); - LoggingLevel loggingLevel = Logger.getLoggingLevel(flowRecordLogEntry.loggingLevelName); - - LogEntryEventBuilder logEntryEventBuilder = Logger.newEntry(loggingLevel, flowRecordLogEntry.message) - .setRecordId(flowRecordLogEntry.record) - .setTopics(flowRecordLogEntry.topics); - - LogEntryEvent__e logEntryEvent = logEntryEventBuilder.getLogEntryEvent(); - - if (logEntryEventBuilder.shouldSave() == false) { - continue; - } - - logEntryEvent.OriginLocation__c = flowRecordLogEntry.flowName; - logEntryEvent.OriginType__c = 'Flow'; - logEntryEvent.Timestamp__c = flowRecordLogEntry.timestamp; - - if (String.isNotBlank(flowRecordLogEntry.faultMessage)) { - logEntryEvent.ExceptionMessage__c = flowRecordLogEntry.faultMessage; - logEntryEvent.ExceptionType__c = 'Flow.FaultError'; - } - - if (flowRecordLogEntry.saveLog == true) { - saveLog = flowRecordLogEntry.saveLog; - } - } - - if (saveLog == true) { - Logger.saveLog(); + shadowLogEntries.add(shadowLogEntry); } - // Event though it's the same transaction ID, Salesforce expects the returned list... - // ...to have the same number of items as the initial input. - // When there's a mismatch, Salesforce throws an error: - // FLOW_ELEMENT_ERROR The number of results does not match the number of interviews that were executed in a single bulk execution request.|FlowActionCall - List transactionIds = new List(); - for (Integer i = 0; i < flowRecordLogEntries.size(); i++) { - transactionIds.add(Logger.getTransactionId()); - } - return transactionIds; + return FlowLogger.addEntries(shadowLogEntries); } } diff --git a/nebula-logger/main/logger-engine/classes/LogEntryEventBuilder.cls b/nebula-logger/main/logger-engine/classes/LogEntryEventBuilder.cls index 37e8b76bb..84c27b0ef 100644 --- a/nebula-logger/main/logger-engine/classes/LogEntryEventBuilder.cls +++ b/nebula-logger/main/logger-engine/classes/LogEntryEventBuilder.cls @@ -375,11 +375,14 @@ global with sharing class LogEntryEventBuilder { return this; } + Schema.SObjectType sobjectType = records.getSObjectType() != null ? records.getSObjectType() : records.get(0).getSObjectType(); + String sobjectTypeName = sobjectType?.getDescribe().getName(); + this.logEntryEvent.RecordCollectionType__c = 'List'; this.logEntryEvent.RecordJson__c = JSON.serializePretty(records); - this.logEntryEvent.RecordSObjectClassification__c = getSObjectClassification(records.getSObjectType()); - this.logEntryEvent.RecordSObjectType__c = records.getSObjectType().getDescribe().getName(); - this.logEntryEvent.RecordSObjectTypeNamespace__c = getSObjectTypeNamespace(records.getSObjectType()); + this.logEntryEvent.RecordSObjectClassification__c = getSObjectClassification(sobjectType); + this.logEntryEvent.RecordSObjectType__c = sobjectTypeName; + this.logEntryEvent.RecordSObjectTypeNamespace__c = getSObjectTypeNamespace(sobjectType); return this; } @@ -748,10 +751,8 @@ global with sharing class LogEntryEventBuilder { if (String.isEmpty(stackTraceString)) { return false; } - if (stackTraceString == '()') { - return false; - } - if (stackTraceString == '(' + NAMESPACE_PREFIX + ')') { + + if (stackTraceString == '()' || stackTraceString == '(' + NAMESPACE_PREFIX + ')') { return false; } diff --git a/nebula-logger/main/logger-engine/permissionsets/LoggerLogCreator.permissionset-meta.xml b/nebula-logger/main/logger-engine/permissionsets/LoggerLogCreator.permissionset-meta.xml index 4c0cb3c51..8fd347888 100644 --- a/nebula-logger/main/logger-engine/permissionsets/LoggerLogCreator.permissionset-meta.xml +++ b/nebula-logger/main/logger-engine/permissionsets/LoggerLogCreator.permissionset-meta.xml @@ -1,9 +1,17 @@ + + FlowCollectionLogEntry + true + FlowLogEntry true + + FlowLogger + true + FlowRecordLogEntry true diff --git a/nebula-logger/tests/log-management/classes/LogMassDeleteExtension_Tests.cls b/nebula-logger/tests/log-management/classes/LogMassDeleteExtension_Tests.cls index d89564ced..01ee1ac78 100644 --- a/nebula-logger/tests/log-management/classes/LogMassDeleteExtension_Tests.cls +++ b/nebula-logger/tests/log-management/classes/LogMassDeleteExtension_Tests.cls @@ -86,4 +86,37 @@ private class LogMassDeleteExtension_Tests { System.assertEquals(false, log.IsDeleted, log); } } + + @isTest + static void it_should_add_error_to_page_when_user_does_not_have_delete_access() { + List logs = [SELECT Id, Name FROM Log__c]; + List logsToDelete = new List(); + List logsToKeep = new List(); + Integer numberToKeep = 3; + for (Integer i = 0; i < logs.size(); i++) { + if (i < numberToKeep) { + logsToDelete.add(logs.get(i)); + } else { + logsToKeep.add(logs.get(i)); + } + } + + ApexPages.StandardSetController controller = new ApexPages.StandardSetController(logs); + controller.setSelected(logsToDelete); + + PageReference pageReference = Page.LogMassDelete; + Test.setCurrentPage(pageReference); + + User chatterUser = [SELECT Id FROM User WHERE Profile.Name = 'Chatter Free User' LIMIT 1]; + System.runAs(chatterUser) { + System.assertEquals(false, Schema.Log__c.SObjectType.getDescribe().isDeletable()); + + String deleteAccessError = 'You do not have access to delete logs records'; + + LogMassDeleteExtension extension = new LogMassDeleteExtension(controller); + + System.assertEquals(true, ApexPages.hasMessages(ApexPages.SEVERITY.ERROR)); + System.assertEquals(deleteAccessError, ApexPages.getMessages().get(0).getSummary()); + } + } } diff --git a/nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls b/nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls new file mode 100644 index 000000000..b5e2bec2c --- /dev/null +++ b/nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls @@ -0,0 +1,200 @@ +//------------------------------------------------------------------------------------------------// +// 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. // +//------------------------------------------------------------------------------------------------// +@isTest +private class FlowCollectionLogEntry_Tests { + static FlowCollectionLogEntry createFlowCollectionLogEntry() { + FlowCollectionLogEntry flowCollectionEntry = new FlowCollectionLogEntry(); + flowCollectionEntry.flowName = 'MyFlowOrProcessBuilder'; + flowCollectionEntry.message = 'my test message'; + flowCollectionEntry.saveLog = false; + + return flowCollectionEntry; + } + + @isTest + static void it_should_save_entry_when_logging_level_met() { + User currentUser = new User( + Id = UserInfo.getUserId(), + FirstName = UserInfo.getFirstName(), + LastName = UserInfo.getLastName(), + Username = UserInfo.getUserName() + ); + + LoggingLevel userLoggingLevel = LoggingLevel.FINEST; + LoggingLevel flowCollectionEntryLoggingLevel = LoggingLevel.DEBUG; + System.assert(userLoggingLevel.ordinal() < flowCollectionEntryLoggingLevel.ordinal()); + + Test.startTest(); + + Logger.getUserSettings().LoggingLevel__c = userLoggingLevel.name(); + Logger.getUserSettings().EnableSystemMessages__c = false; + + FlowCollectionLogEntry flowCollectionEntry = createFlowCollectionLogEntry(); + flowCollectionEntry.loggingLevelName = flowCollectionEntryLoggingLevel.name(); + flowCollectionEntry.records = new List{ currentUser }; + FlowCollectionLogEntry.addFlowCollectionEntries(new List{ flowCollectionEntry }); + + System.assertEquals(1, Logger.getBufferSize()); + + Logger.saveLog(); + + Test.stopTest(); + + String expectedUserJson = JSON.serializePretty(new List{ currentUser }); + + LogEntry__c logEntry = [ + SELECT Id, LoggingLevel__c, Message__c, OriginType__c, OriginLocation__c, RecordId__c, RecordCollectionType__c, RecordJson__c, RecordSObjectType__c + FROM LogEntry__c + ORDER BY CreatedDate + LIMIT 1 + ]; + System.assertEquals(flowCollectionEntry.loggingLevelName, logEntry.LoggingLevel__c); + System.assertEquals(flowCollectionEntry.message, logEntry.Message__c); + System.assertEquals('Flow', logEntry.OriginType__c); + System.assertEquals(null, logEntry.RecordId__c); + System.assertEquals('List', logEntry.RecordCollectionType__c); + System.assertEquals('User', logEntry.RecordSObjectType__c); + System.assertEquals(expectedUserJson, logEntry.RecordJson__c); + } + + @isTest + static void it_should_auto_save_entry_when_saveLog_is_true() { + User currentUser = new User( + Id = UserInfo.getUserId(), + FirstName = UserInfo.getFirstName(), + LastName = UserInfo.getLastName(), + Username = UserInfo.getUserName() + ); + + LoggingLevel userLoggingLevel = LoggingLevel.FINEST; + LoggingLevel flowCollectionEntryLoggingLevel = LoggingLevel.DEBUG; + System.assert(userLoggingLevel.ordinal() < flowCollectionEntryLoggingLevel.ordinal()); + + Test.startTest(); + + Logger.getUserSettings().LoggingLevel__c = userLoggingLevel.name(); + Logger.getUserSettings().EnableSystemMessages__c = false; + + FlowCollectionLogEntry flowCollectionEntry = createFlowCollectionLogEntry(); + flowCollectionEntry.loggingLevelName = flowCollectionEntryLoggingLevel.name(); + flowCollectionEntry.records = new List{ currentUser }; + flowCollectionEntry.saveLog = true; + FlowCollectionLogEntry.addFlowCollectionEntries(new List{ flowCollectionEntry }); + + Test.stopTest(); + + String expectedUserJson = JSON.serializePretty(new List{ currentUser }); + + LogEntry__c logEntry = [ + SELECT Id, LoggingLevel__c, Message__c, OriginType__c, OriginLocation__c, RecordId__c, RecordCollectionType__c, RecordJson__c, RecordSObjectType__c + FROM LogEntry__c + ORDER BY CreatedDate + LIMIT 1 + ]; + System.assertEquals(flowCollectionEntry.loggingLevelName, logEntry.LoggingLevel__c); + System.assertEquals(flowCollectionEntry.message, logEntry.Message__c); + System.assertEquals('Flow', logEntry.OriginType__c); + System.assertEquals(null, logEntry.RecordId__c); + System.assertEquals('List', logEntry.RecordCollectionType__c); + System.assertEquals('User', logEntry.RecordSObjectType__c); + System.assertEquals(expectedUserJson, logEntry.RecordJson__c); + } + + @isTest + static void it_should_not_save_entry_when_logging_level_not_met() { + User currentUser = new User( + Id = UserInfo.getUserId(), + FirstName = UserInfo.getFirstName(), + LastName = UserInfo.getLastName(), + Username = UserInfo.getUserName() + ); + + LoggingLevel userLoggingLevel = LoggingLevel.ERROR; + LoggingLevel flowCollectionEntryLoggingLevel = LoggingLevel.DEBUG; + System.assert(userLoggingLevel.ordinal() > flowCollectionEntryLoggingLevel.ordinal()); + + Test.startTest(); + + Logger.getUserSettings().LoggingLevel__c = userLoggingLevel.name(); + Logger.getUserSettings().EnableSystemMessages__c = false; + + FlowCollectionLogEntry flowCollectionEntry = createFlowCollectionLogEntry(); + flowCollectionEntry.loggingLevelName = flowCollectionEntryLoggingLevel.name(); + flowCollectionEntry.records = new List{ currentUser }; + FlowCollectionLogEntry.addFlowCollectionEntries(new List{ flowCollectionEntry }); + + System.assertEquals(0, Logger.getBufferSize()); + + Test.stopTest(); + } + + @isTest + static void it_should_use_debug_as_default_level_when_faultMessage_is_null() { + LoggingLevel expectedEntryLoggingLevel = LoggingLevel.DEBUG; + + Test.startTest(); + + Logger.getUserSettings().LoggingLevel__c = expectedEntryLoggingLevel.name(); + Logger.getUserSettings().EnableSystemMessages__c = false; + + FlowCollectionLogEntry flowEntry = createFlowCollectionLogEntry(); + System.assertEquals(null, flowEntry.faultMessage); + System.assertEquals(null, flowEntry.loggingLevelName); + + FlowCollectionLogEntry.addFlowCollectionEntries(new List{ flowEntry }); + + System.assertEquals(1, Logger.getBufferSize()); + + Logger.saveLog(); + + Test.stopTest(); + + LogEntry__c logEntry = [ + SELECT Id, ExceptionMessage__c, ExceptionType__c, LoggingLevel__c, Message__c, OriginType__c, OriginLocation__c, RecordId__c, RecordCollectionType__c, RecordJson__c + FROM LogEntry__c + ORDER BY CreatedDate + LIMIT 1 + ]; + System.assertEquals(null, logEntry.ExceptionMessage__c); + System.assertEquals(null, logEntry.ExceptionType__c); + System.assertEquals(expectedEntryLoggingLevel.name(), logEntry.LoggingLevel__c); + System.assertEquals(flowEntry.message, logEntry.Message__c); + System.assertEquals('Flow', logEntry.OriginType__c); + } + + @isTest + static void it_should_use_error_as_default_level_when_faultMessage_is_not_null() { + LoggingLevel expectedEntryLoggingLevel = LoggingLevel.ERROR; + + Test.startTest(); + + Logger.getUserSettings().LoggingLevel__c = LoggingLevel.FINEST.name(); + Logger.getUserSettings().EnableSystemMessages__c = false; + + FlowCollectionLogEntry flowEntry = createFlowCollectionLogEntry(); + flowEntry.faultMessage = 'Whoops, a Flow error has occurred.'; + System.assertEquals(null, flowEntry.loggingLevelName); + + FlowCollectionLogEntry.addFlowCollectionEntries(new List{ flowEntry }); + + System.assertEquals(1, Logger.getBufferSize()); + + Logger.saveLog(); + + Test.stopTest(); + + LogEntry__c logEntry = [ + SELECT Id, ExceptionMessage__c, ExceptionType__c, LoggingLevel__c, Message__c, OriginType__c, OriginLocation__c, RecordId__c, RecordCollectionType__c, RecordJson__c + FROM LogEntry__c + ORDER BY CreatedDate + LIMIT 1 + ]; + System.assertEquals(flowEntry.faultMessage, logEntry.ExceptionMessage__c); + System.assertEquals('Flow.FaultError', logEntry.ExceptionType__c); + System.assertEquals(expectedEntryLoggingLevel.name(), logEntry.LoggingLevel__c); + System.assertEquals(flowEntry.message, logEntry.Message__c); + System.assertEquals('Flow', logEntry.OriginType__c); + } +} diff --git a/nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls-meta.xml b/nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls-meta.xml new file mode 100644 index 000000000..d75b0582f --- /dev/null +++ b/nebula-logger/tests/logger-engine/classes/FlowCollectionLogEntry_Tests.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/nebula-logger/tests/logger-engine/classes/FlowLogEntry_Tests.cls b/nebula-logger/tests/logger-engine/classes/FlowLogEntry_Tests.cls index 537f6c248..ff846908d 100644 --- a/nebula-logger/tests/logger-engine/classes/FlowLogEntry_Tests.cls +++ b/nebula-logger/tests/logger-engine/classes/FlowLogEntry_Tests.cls @@ -103,7 +103,6 @@ private class FlowLogEntry_Tests { System.assertEquals(null, flowEntry.loggingLevelName); FlowLogEntry.addFlowEntries(new List{ flowEntry }); - System.assertEquals(expectedEntryLoggingLevel.name(), flowEntry.loggingLevelName); System.assertEquals(1, Logger.getBufferSize()); @@ -138,7 +137,6 @@ private class FlowLogEntry_Tests { System.assertEquals(null, flowEntry.loggingLevelName); FlowLogEntry.addFlowEntries(new List{ flowEntry }); - System.assertEquals(expectedEntryLoggingLevel.name(), flowEntry.loggingLevelName); System.assertEquals(1, Logger.getBufferSize()); diff --git a/nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls b/nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls new file mode 100644 index 000000000..41d90e572 --- /dev/null +++ b/nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------------------------// +// 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. // +//------------------------------------------------------------------------------------------------// +@isTest +private class FlowLogger_Tests { + @isTest + static void it_should_add_entry_to_logger_buffer() { + LoggingLevel entryLoggingLevel = LoggingLevel.DEBUG; + + Test.startTest(); + + Logger.getUserSettings().LoggingLevel__c = entryLoggingLevel.name(); + + FlowLogger.LogEntry logEntry = new FlowLogger.LogEntry(); + logEntry.flowName = 'MyFlow'; + logEntry.message = 'hello from Flow'; + logEntry.loggingLevelName = entryLoggingLevel.name(); + logEntry.saveLog = false; + logEntry.timestamp = System.now(); + + System.assertEquals(0, Logger.getBufferSize()); + System.assertEquals(0, [SELECT COUNT() FROM LogEntry__c]); + + FlowLogger.addEntries(new List{ logEntry }); + System.assertEquals(1, Logger.getBufferSize()); + + Logger.saveLog(); + + Test.stopTest(); + + System.assertEquals(1, [SELECT COUNT() FROM LogEntry__c]); + } + + @isTest + static void it_should_auto_save_entry_when_saveLog_is_true() { + LoggingLevel entryLoggingLevel = LoggingLevel.DEBUG; + + Test.startTest(); + + Logger.getUserSettings().LoggingLevel__c = entryLoggingLevel.name(); + + FlowLogger.LogEntry logEntry = new FlowLogger.LogEntry(); + logEntry.flowName = 'MyFlow'; + logEntry.message = 'hello from Flow'; + logEntry.loggingLevelName = entryLoggingLevel.name(); + logEntry.saveLog = true; + logEntry.timestamp = System.now(); + + System.assertEquals(0, Logger.getBufferSize()); + System.assertEquals(0, [SELECT COUNT() FROM LogEntry__c]); + + FlowLogger.addEntries(new List{ logEntry }); + System.assertEquals(0, Logger.getBufferSize()); + + Test.stopTest(); + + System.assertEquals(1, [SELECT COUNT() FROM LogEntry__c]); + } +} diff --git a/nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls-meta.xml b/nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls-meta.xml new file mode 100644 index 000000000..d75b0582f --- /dev/null +++ b/nebula-logger/tests/logger-engine/classes/FlowLogger_Tests.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/nebula-logger/tests/logger-engine/classes/FlowRecordLogEntry_Tests.cls b/nebula-logger/tests/logger-engine/classes/FlowRecordLogEntry_Tests.cls index acdc75f50..259b4ccbf 100644 --- a/nebula-logger/tests/logger-engine/classes/FlowRecordLogEntry_Tests.cls +++ b/nebula-logger/tests/logger-engine/classes/FlowRecordLogEntry_Tests.cls @@ -140,7 +140,6 @@ private class FlowRecordLogEntry_Tests { System.assertEquals(null, flowEntry.loggingLevelName); FlowRecordLogEntry.addFlowRecordEntries(new List{ flowEntry }); - System.assertEquals(expectedEntryLoggingLevel.name(), flowEntry.loggingLevelName); System.assertEquals(1, Logger.getBufferSize()); @@ -175,7 +174,6 @@ private class FlowRecordLogEntry_Tests { System.assertEquals(null, flowEntry.loggingLevelName); FlowRecordLogEntry.addFlowRecordEntries(new List{ flowEntry }); - System.assertEquals(expectedEntryLoggingLevel.name(), flowEntry.loggingLevelName); System.assertEquals(1, Logger.getBufferSize()); diff --git a/package.json b/package.json index d719ecda4..c1c5e49f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula-logger", - "version": "4.5.0", + "version": "4.5.1", "description": "Designed for Salesforce admins, developers & architects. A robust logger for Apex, Flow, Process Builder & Integrations.", "scripts": { "deploy": "npm run deploy:logger && npm run deploy:managedpackage && npm run deploy:extratests", diff --git a/scripts/generate-docs.ps1 b/scripts/generate-docs.ps1 index 0af7fb704..3c64e048e 100644 --- a/scripts/generate-docs.ps1 +++ b/scripts/generate-docs.ps1 @@ -1,5 +1,5 @@ # This script is used to generate the markdown files used by Github pages -npx apexdocs-generate --configPath config/apexdocs.json --scope global public --sourceDir nebula-logger/ --targetDir docs +npx apexdocs-generate --configPath config/apexdocs.json --scope global public --sourceDir nebula-logger/main/ --targetDir docs # Make a few adjustments to the generated markdown files so that they work correctly in Github Pages $indexPageFile = "docs/index.md" diff --git a/sfdx-project.json b/sfdx-project.json index 5dfd48e0c..48b0cf9ea 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -12,9 +12,9 @@ "path": "nebula-logger", "default": false, "definitionFile": "config/project-scratch-def-with-experience-cloud.json", - "versionName": "Logger Plugin Framework", - "versionNumber": "4.5.0.0", - "versionDescription": "Easily build or install plugins that enhance the Log__c and LogEntry__c objects, using Apex or Flow", + "versionName": "Flow Collection Logging", + "versionNumber": "4.5.1.0", + "versionDescription": "Adds the ability to log a record collection in Flow", "releaseNotesUrl": "https://github.com/jongpie/NebulaLogger/releases" }, { @@ -44,6 +44,7 @@ "Nebula Logger - Unlocked Package@4.4.5-0-log-batch-purger-bugfixes": "04t5Y0000027FIVQA2", "Nebula Logger - Unlocked Package@4.4.6-0-new-save-method-synchronous_dml": "04t5Y0000027FJdQAM", "Nebula Logger - Unlocked Package@4.5.0-0-logger-plugin-framework": "04t5Y0000027FMrQAM", + "Nebula Logger - Unlocked Package@4.5.1-0-flow-collection-log-entry": "04t5Y0000027FN6QAM", "Nebula Logger Plugin - Slack": "0Ho5e000000oM3pCAE", "Nebula Logger Plugin - Slack@0.9.0-0-beta-release": "04t5e00000061lHAAQ" }