Skip to content

Commit

Permalink
Added support for logging Apex errors in LWC/Aura logging (#323)
Browse files Browse the repository at this point in the history
* Resolved #299 by adding support for logging Apex errors in logger.js, fixed some issues in ComponentLogger.cls with null values for setRecord(Id) and setRecord(SObject)

* Added a button + JS function in loggerLWCDemo.js to call an Apex method that throws an exception

* Improved stack trace parsing in ComponentLogger.cls to better handle the stack traces that originate from async JS functions (such as Apex controller method calls)

* Cleaned up the formatting of several Jest tests to use arrange-act-assert formatting, removed usage of deprecated Jest import registerApexTestWireAdapter
  • Loading branch information
jongpie authored Jun 3, 2022
1 parent 83d2e34 commit e120ff1
Show file tree
Hide file tree
Showing 16 changed files with 485 additions and 1,142 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

Designed for Salesforce admins, developers & architects. A robust logger for Apex, Lightning Components, Flow, Process Builder & Integrations.

## Unlocked Package - v4.7.5
## Unlocked Package - v4.7.6

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

## Managed Package - v4.7.0
Expand Down
6 changes: 3 additions & 3 deletions docs/lightning-components/LogEntryBuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ Sets the log entry event's exception fields
**Kind**: instance method of [<code>LogEntryBuilder</code>](#LogEntryBuilder)
**Returns**: [<code>LogEntryBuilder</code>](#LogEntryBuilder) - The same instance of `LogEntryBuilder`, useful for chaining methods

| Param | Type | Description |
| ----- | ------------------ | --------------------------------------------------- |
| error | <code>Error</code> | The instance of a JavaScript `Error` object to use. |
| Param | Type | Description |
| ----- | ------------------ | -------------------------------------------------------------------------------- |
| error | <code>Error</code> | The instance of a JavaScript `Error` object to use, or an Apex HTTP error to use |

<a name="LogEntryBuilder+addTag"></a>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import { createElement } from 'lwc';
import LogViewer from 'c/logViewer';
import getLog from '@salesforce/apex/LogViewerController.getLog';
import { registerApexTestWireAdapter } from '@salesforce/sfdx-lwc-jest';

// Mock data
const mockGetLog = require('./data/LogViewerController.getLog.json');

// Register a test wire adapter
const getLogAdapter = registerApexTestWireAdapter(getLog);
const MOCK_GET_LOG = require('./data/LogViewerController.getLog.json');

document.execCommand = jest.fn();

jest.mock(
'@salesforce/apex/LogViewerController.getLog',
() => {
const { createApexTestWireAdapter } = require('@salesforce/sfdx-lwc-jest');
return {
default: () => mockGetLog
default: createApexTestWireAdapter(jest.fn())
};
},
{ virtual: true }
Expand All @@ -35,77 +31,67 @@ describe('Logger JSON Viewer lwc tests', () => {
it('sets document title', async () => {
const logViewerElement = createElement('c-log-viewer', { is: LogViewer });
document.body.appendChild(logViewerElement);
getLogAdapter.emit(mockGetLog);
getLog.emit({ ...MOCK_GET_LOG });

await Promise.resolve();
expect(logViewerElement.title).toEqual(mockGetLog.Name);
expect(logViewerElement.title).toEqual(MOCK_GET_LOG.Name);
});

it('defaults to brand button variant', async () => {
const logViewer = createElement('c-log-viewer', { is: LogViewer });
document.body.appendChild(logViewer);
getLog.emit({ ...MOCK_GET_LOG });
await Promise.resolve('resolves component rerender after loading log record');

getLogAdapter.emit(mockGetLog);

await Promise.resolve();
const inputButton = logViewer.shadowRoot.querySelector('lightning-button-stateful');
expect(logViewer.title).toEqual(mockGetLog.Name);

expect(logViewer.title).toEqual(MOCK_GET_LOG.Name);
expect(inputButton.variant).toEqual('brand');
});

it('copies the JSON to the clipboard', async () => {
const logViewer = createElement('c-log-viewer', { is: LogViewer });
document.body.appendChild(logViewer);

getLogAdapter.emit(mockGetLog);

return Promise.resolve()
.then(() => {
let copyBtn = logViewer.shadowRoot.querySelector('lightning-button-stateful[data-id="copy-btn"]');
copyBtn.click();
})
.then(() => {
const tab = logViewer.shadowRoot.querySelector('lightning-tab[data-id="json-content"]');
expect(tab.value).toEqual('json');
tab.dispatchEvent(new CustomEvent('active'));
})
.then(() => {
const clipboardContent = JSON.parse(logViewer.shadowRoot.querySelector('pre').textContent);
expect(clipboardContent).toEqual(mockGetLog);
expect(document.execCommand).toHaveBeenCalledWith('copy');
});
getLog.emit({ ...MOCK_GET_LOG });
await Promise.resolve('resolves component rerender after loading log record');

let copyBtn = logViewer.shadowRoot.querySelector('lightning-button-stateful[data-id="copy-btn"]');
copyBtn.click();

await Promise.resolve('resolves copy-to-clipboard function');
const tab = logViewer.shadowRoot.querySelector('lightning-tab[data-id="json-content"]');
expect(tab.value).toEqual('json');
tab.dispatchEvent(new CustomEvent('active'));
await Promise.resolve('resolves dispatchEvent() for tab');
const clipboardContent = JSON.parse(logViewer.shadowRoot.querySelector('pre').textContent);
expect(clipboardContent).toEqual(MOCK_GET_LOG);
expect(document.execCommand).toHaveBeenCalledWith('copy');
});

it('copies the log file to the clipboard', async () => {
const logViewer = createElement('c-log-viewer', { is: LogViewer });
document.body.appendChild(logViewer);

getLogAdapter.emit(mockGetLog);

return Promise.resolve()
.then(() => {
let copyBtn = logViewer.shadowRoot.querySelector('lightning-button-stateful[data-id="copy-btn"]');
copyBtn.click();
})
.then(() => {
const tab = logViewer.shadowRoot.querySelector('lightning-tab[data-id="file-content"]');
expect(tab.value).toEqual('file');
tab.dispatchEvent(new CustomEvent('active'));
})
.then(() => {
let expectedContentLines = [];
mockGetLog.LogEntries__r.forEach(logEntry => {
const columns = [];
columns.push('[' + new Date(logEntry.EpochTimestamp__c).toISOString() + ' - ' + logEntry.LoggingLevel__c + ']');
columns.push('[Message]\n' + logEntry.Message__c);
columns.push('\n[Stack Trace]\n' + logEntry.StackTrace__c);

expectedContentLines.push(columns.join('\n'));
});

const clipboardContent = logViewer.shadowRoot.querySelector('pre').textContent;
expect(clipboardContent).toEqual(expectedContentLines.join('\n\n' + '-'.repeat(36) + '\n\n'));
expect(document.execCommand).toHaveBeenCalledWith('copy');
});
getLog.emit({ ...MOCK_GET_LOG });
await Promise.resolve('resolves component rerender after loading log record');

let copyBtn = logViewer.shadowRoot.querySelector('lightning-button-stateful[data-id="copy-btn"]');
copyBtn.click();

await Promise.resolve('resolves copy-to-clipboard function');
const tab = logViewer.shadowRoot.querySelector('lightning-tab[data-id="file-content"]');
expect(tab.value).toEqual('file');
tab.dispatchEvent(new CustomEvent('active'));
await Promise.resolve('resolves dispatchEvent() for tab');
let expectedContentLines = [];
MOCK_GET_LOG.LogEntries__r.forEach(logEntry => {
const columns = [];
columns.push('[' + new Date(logEntry.EpochTimestamp__c).toISOString() + ' - ' + logEntry.LoggingLevel__c + ']');
columns.push('[Message]\n' + logEntry.Message__c);
columns.push('\n[Stack Trace]\n' + logEntry.StackTrace__c);

expectedContentLines.push(columns.join('\n'));
});
const clipboardContent = logViewer.shadowRoot.querySelector('pre').textContent;
expect(clipboardContent).toEqual(expectedContentLines.join('\n\n' + '-'.repeat(36) + '\n\n'));
expect(document.execCommand).toHaveBeenCalledWith('copy');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,15 @@ import { registerApexTestWireAdapter } from '@salesforce/sfdx-lwc-jest';
import RelatedLogEntries from 'c/relatedLogEntries';
import getQueryResult from '@salesforce/apex/RelatedLogEntriesController.getQueryResult';

// Mock data
const mockRecordId = '0015400000gY3OuAAK';
const mockQueryResult = require('./data/getQueryResult.json');

// Register a test wire adapter
const getQueryResultAdapter = registerApexTestWireAdapter(getQueryResult);

function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0));
}
const MOCK_RECORD_ID = '0015400000gY3OuAAK';
const MOCK_QUERY_RESULT = require('./data/getQueryResult.json');

jest.mock(
'@salesforce/apex/RelatedLogEntriesController.getQueryResult',
() => {
const { createApexTestWireAdapter } = require('@salesforce/sfdx-lwc-jest');
return {
default: () => jest.fn()
default: createApexTestWireAdapter(jest.fn())
};
},
{ virtual: true }
Expand All @@ -34,16 +27,14 @@ describe('Related Log Entries lwc tests', () => {

it('sets query result', async () => {
const relatedLogEntriesElement = createElement('c-related-log-entries', { is: RelatedLogEntries });
relatedLogEntriesElement.recordId = mockRecordId;
relatedLogEntriesElement.recordId = MOCK_RECORD_ID;
document.body.appendChild(relatedLogEntriesElement);

getQueryResultAdapter.emit(mockQueryResult);
getQueryResult.emit({ ...MOCK_QUERY_RESULT });

// Resolve a promise to wait for a rerender of the new content
return flushPromises().then(() => {
expect(relatedLogEntriesElement.queryResult).toBeTruthy();
expect(relatedLogEntriesElement.queryResult.records[0].Id).toEqual(mockQueryResult.records[0].Id);
// expect(relatedLogEntriesElement.fieldSetName).not.toBe(undefined);
});
await Promise.resolve();
expect(relatedLogEntriesElement.queryResult).toBeTruthy();
expect(relatedLogEntriesElement.queryResult.records[0].Id).toEqual(MOCK_QUERY_RESULT.records[0].Id);
// expect(relatedLogEntriesElement.fieldSetName).not.toBe(undefined);
});
});
41 changes: 24 additions & 17 deletions nebula-logger/core/main/logger-engine/classes/ComponentLogger.cls
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@
*/
@SuppressWarnings('PMD.ExcessivePublicCount, PMD.StdCyclomaticComplexity')
public inherited sharing class ComponentLogger {
@TestVisible
private static final String EXCEPTION_TYPE_PREFIX = 'JavaScript.';

/**
* @description Provides data to the frontend about `LoggerSettings__c` & server-supported logging details
* @return return The instance of `ComponentLoggerSettings` for the current user
Expand All @@ -37,10 +34,14 @@ public inherited sharing class ComponentLogger {
for (ComponentLogEntry componentLogEntry : componentLogEntries) {
Logger.setScenario(componentLogEntry.scenario);
LoggingLevel entryLoggingLevel = Logger.getLoggingLevel(componentLogEntry.loggingLevel);
LogEntryEventBuilder logEntryEventBuilder = Logger.newEntry(entryLoggingLevel, componentLogEntry.message)
.setRecord(componentLogEntry.recordId)
.setRecord(componentLogEntry.record)
.addTags(componentLogEntry.tags);
LogEntryEventBuilder logEntryEventBuilder = Logger.newEntry(entryLoggingLevel, componentLogEntry.message).addTags(componentLogEntry.tags);

if (componentLogEntry.recordId != null) {
logEntryEventBuilder.setRecord(componentLogEntry.recordId);
}
if (componentLogEntry.record != null) {
logEntryEventBuilder.setRecord(componentLogEntry.record);
}

logEntryEventBuilder.getLogEntryEvent().Timestamp__c = componentLogEntry.timestamp;
setComponentErrorDetails(logEntryEventBuilder, componentLogEntry.error);
Expand All @@ -66,10 +67,10 @@ public inherited sharing class ComponentLogger {

logEntryEventBuilder.getLogEntryEvent().ExceptionMessage__c = componentError.message;
logEntryEventBuilder.getLogEntryEvent().ExceptionStackTrace__c = componentError.stack;
logEntryEventBuilder.getLogEntryEvent().ExceptionType__c = EXCEPTION_TYPE_PREFIX + componentError.type;
logEntryEventBuilder.getLogEntryEvent().ExceptionType__c = componentError.type;
}

@SuppressWarnings('PMD.StdCyclomaticComplexity, PMD.CyclomaticComplexity, PMD.CognitiveComplexity, PMD.AvoidDeeplyNestedIfStmts')
@SuppressWarnings('PMD.AvoidDeeplyNestedIfStmts, PMD.CyclomaticComplexity, PMD.CognitiveComplexity, PMD.NcssMethodCount, PMD.StdCyclomaticComplexity')
private static void setStackTraceDetails(LogEntryEventBuilder logEntryEventBuilder, String stackTraceString) {
String originLocation;
Boolean isAuraComponent = false;
Expand Down Expand Up @@ -97,17 +98,23 @@ public inherited sharing class ComponentLogger {
}

stackTraceString = String.join(stackTraceLines, '\n');
if (String.isNotBlank(stackTraceString)) {
if (String.isNotBlank(stackTraceString) == true) {
String componentName;
String componentFunction;
if (isAuraComponent == true) {
componentName = stackTraceLines.get(0).substringAfterLast(auraComponentContent).substringBefore('.js');
componentFunction = stackTraceLines.get(0).substringBefore(' (').substringAfter('at ');
} else {
componentName = stackTraceLines.get(0).substringAfterLast(lwcModuleContent).substringBefore('.js');
componentFunction = stackTraceLines.get(0).substringBefore(' (').substringAfter('at ').substringAfter('.');
for (String currentStackTraceLine : stackTraceString.split('\n')) {
if (currentStackTraceLine.trim().startsWith('at eval') == true) {
continue;
}

if (isAuraComponent == true) {
componentName = currentStackTraceLine.substringAfterLast(auraComponentContent).substringBefore('.js');
componentFunction = currentStackTraceLine.substringBefore(' (').substringAfter('at ');
} else {
componentName = currentStackTraceLine.substringAfterLast(lwcModuleContent).substringBefore('.js');
componentFunction = currentStackTraceLine.substringBefore(' (').substringAfter('at ').substringAfter('.');
}
break;
}

originLocation = componentName + '.' + componentFunction;
}
}
Expand Down
2 changes: 1 addition & 1 deletion nebula-logger/core/main/logger-engine/classes/Logger.cls
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
global with sharing class Logger {
// There's no reliable way to get the version number dynamically in Apex
@TestVisible
private static final String CURRENT_VERSION_NUMBER = 'v4.7.5';
private static final String CURRENT_VERSION_NUMBER = 'v4.7.6';
private static final LoggingLevel DEFAULT_LOGGING_LEVEL = LoggingLevel.DEBUG;
private static final List<LogEntryEventBuilder> LOG_ENTRIES_BUFFER = new List<LogEntryEventBuilder>();
private static final Map<String, LogScenarioRule__mdt> MOCK_SCENARIO_TO_SCENARIO_RULE = new Map<String, LogScenarioRule__mdt>();
Expand Down
Loading

0 comments on commit e120ff1

Please sign in to comment.