Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better exception email parsing #25

Merged
merged 2 commits into from
Oct 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 128 additions & 33 deletions force-app/main/default/classes/ExceptionEmailParser.cls
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
public with sharing class ExceptionEmailParser {
public static ExceptionData parse(String emailBody) {
List<String> lines = emailBody.split('\n');

ExceptionData exData = new ExceptionData(
parseEnvironment(emailBody),
parseUserOrg(emailBody),
parseClassName(emailBody),
parseMessage(emailBody),
parseFileName(emailBody),
parseContext(emailBody),
parseLine(emailBody),
parseColumn(emailBody)
);
Map<String, Object> exDataMap = parseLines(lines);

ExceptionData exData = ExceptionData.fromMap(exDataMap);

return exData;
}
Expand All @@ -21,66 +15,167 @@ public with sharing class ExceptionEmailParser {
return fromName;
}

public static String parseEnvironment(String emailBody) {
return emailBody.split('\\n')[0];
// Exception email bodies are generally unstructured text, with a few
// (somewhat) consistent markers that can be used to extract the
// ExceptionData elements.
//
// The parser is a simple state machine that iterates lines looking first
// for the user/org line, then captures context lines until finding the
// 'caused by' line. Finally it looks for the first stack frame line.
// Blank lines are ignored.
//
// Not all emails will have the 'caused by' line, nor stack frame lines.
// Some emails will have multiple context lines.
//
private enum ParseState {USER_ORG, CONTEXT, STACK, STOP}

private static Map<String, Object> parseLines(List<String> lines) {
Map<String, Object> dataMap = new Map<String, Object>();
ParseState state = ParseState.USER_ORG;
List<String> contextLines = new List<String>();
String prevLine = null;

for (String line : lines) {
if (String.isBlank(line)) { continue; }

switch on state {
when USER_ORG {
state = handleUserOrgLine(line, prevLine, dataMap);
prevLine = line;
}
when CONTEXT {
state = handleContextLine(line, contextLines, dataMap);
}
when STACK {
state = handleStackLine(line, dataMap);
}
when STOP {
break;
}
}
}

if (state == ParseState.USER_ORG) {
// Handle unknown email format case where state is still USER_ORG.
dataMap.put('message', 'Unknown Error');
dataMap.put('context', String.join(lines, '\n'));
} else {
dataMap.put('context', String.join(contextLines, '\n'));
}

return dataMap;
}

private static ParseState handleUserOrgLine(String line, String prevLine, Map<String, Object> dataMap) {
String userOrg = parseUserOrg(line);

if (userOrg != null && !String.isBlank(userOrg)) {
dataMap.put('organization', userOrg);
return ParseState.CONTEXT;
}

// The user/org data may span two lines.
// Try concatenating with the previous line.
if (prevLine != null){
userOrg = parseUserOrg(prevLine + ' ' + line);

if (userOrg != null) {
dataMap.put('organization', userOrg);
return ParseState.CONTEXT;
}
}

return ParseState.USER_ORG;
}

private static ParseState handleContextLine(String line, List<String> contextLines, Map<String, Object> dataMap) {
if (line.startsWith('caused by:')) {
dataMap.put('className', parseClassName(line));
dataMap.put('message', parseMessage(line));
return ParseState.STACK;
} else {
// Add to context
contextLines.add(line);
}

return ParseState.CONTEXT;
}

private static ParseState handleStackLine(String line, Map<String, Object> dataMap) {
String fileName = parseFileName(line);
Integer lineno = parseLineno(line);
Integer colno = parseColno(line);

if (lineno != null && colno != null) { // tolerates missing fileName
dataMap.put('fileName', fileName);
dataMap.put('line', lineno);
dataMap.put('column', colno);
return ParseState.STOP;
}

return ParseState.STACK;
}

public static String parseUserOrg(String emailBody) {
public static String parseUserOrg(String line) {
return parseContent(
'Apex script unhandled( trigger)? exception by user/organization:(\n| )?(.*)',
emailBody,
line,
3
);
}

public static String parseClassName(String emailBody) {
public static String parseClassName(String line) {
return parseContent(
'caused by: ([^:]*):.*',
emailBody,
line,
1
);
}

public static String parseMessage(String emailBody) {
public static String parseMessage(String line) {
return parseContent(
'caused by: [^:]*: (.*)',
emailBody,
line,
1
);
}

public static String parseFileName(String emailBody) {
public static String parseFileName(String line) {
return parseContent(
'(.*): line [0-9]+, column [0-9]+',
emailBody,
line,
1
);
}

public static String parseContext(String emailBody) {
return emailBody.split('\\n')[4];
}

public static Integer parseLine(String emailBody) {
return Integer.valueOf(parseContent(
public static Integer parseLineno(String line) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Style fix: should be "parseLineNo"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was about to make this change throughout the code, but then noticed the Rollbar API uses lineno and colno with lowercase n and no underscore before the lowercase n. Then I was curious about usage in general, across languages, and it seems pretty well split between people using uppercase or lowercase n.

For consistency with the use of lineno and colno in the code I'm inclined to keep this as is.

string str = parseContent(
'.*: line ([0-9]+), column [0-9]+',
emailBody,
line,
1
));
);

return ((str != null) ? Integer.valueOf(str) : null);
}

public static Integer parseColumn(String emailBody) {
return Integer.valueOf(parseContent(
public static Integer parseColno(String line) {
string str = parseContent(
'.*: line [0-9]+, column ([0-9]+)',
emailBody,
line,
1
));
);

return ((str != null) ? Integer.valueOf(str) : null);
}

private static String parseContent(String regex, String body, Integer groupToReturn) {
Pattern pat = Pattern.compile(regex);
Matcher mat = pat.matcher(body);
mat.find();
return mat.group(groupToReturn);
try {
return mat.group(groupToReturn);
} catch (StringException e) {
return null; // when no match is found
}
}
}
87 changes: 69 additions & 18 deletions force-app/main/default/tests/ExceptionEmailParserTest.cls
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
@isTest
public class ExceptionEmailParserTest {

private static String emailBody =
'Sandbox\n\n' +
private static String emailBody =
'Sandbox\n\n' +
'Apex script unhandled exception by user/organization: 0050R000001t3Br/00D0R000000DUxZ' + '\n\n' +
'ContactEx: execution of AfterInsert' + '\n\n' +
'caused by: System.NullPointerException: Attempt to de-reference a null object' + '\n\n' +
Expand All @@ -12,7 +12,7 @@ public class ExceptionEmailParserTest {
public static void testParse() {
ExceptionData exData = ExceptionEmailParser.parse(emailBody);

System.assertEquals('Sandbox', exData.environment());
System.assertEquals(null, exData.environment());
System.assertEquals('0050R000001t3Br/00D0R000000DUxZ', exData.userOrg());
System.assertEquals('System.NullPointerException', exData.className());
System.assertEquals('Attempt to de-reference a null object', exData.message());
Expand All @@ -23,9 +23,66 @@ public class ExceptionEmailParserTest {
}

@isTest
public static void testParseEnvironment() {
String result = ExceptionEmailParser.parseEnvironment(emailBody);
System.assertEquals('Sandbox', result);
public static void testParseMultiLineUser() {
String emailBody =
'Apex script unhandled exception by user/organization:' + '\n' +
'0050R000001t3Br/00D0R000000DUxZ' + '\n\n' +
'ContactEx: execution of AfterInsert' + '\n\n' +
'caused by: System.NullPointerException: Attempt to de-reference a null object' + '\n\n' +
'Trigger.ContactEx: line 3, column 1';

ExceptionData exData = ExceptionEmailParser.parse(emailBody);

System.assertEquals(null, exData.environment());
System.assertEquals('0050R000001t3Br/00D0R000000DUxZ', exData.userOrg());
System.assertEquals('System.NullPointerException', exData.className());
System.assertEquals('Attempt to de-reference a null object', exData.message());
System.assertEquals('Trigger.ContactEx', exData.fileName());
System.assertEquals('ContactEx: execution of AfterInsert', exData.context());
System.assertEquals(3, exData.line());
System.assertEquals(1, exData.column());
}

@isTest
public static void testParseNoStack() {
String emailBody =
'Sandbox\n\n' +
'Apex script unhandled exception by user/organization: 0050R000001t3Br/00D0R000000DUxZ' + '\n' +
'Source organization: 00D0R000000DUxZ (null)' + '\n' +
'Failed to process batch for class \'ccrz.ccProductIndexCleanupJob\' for job id \'8061F00001btixO\'' + '\n\n' +
'caused by: System.DmlException: Delete failed. First exception on row 5 with id b4r2E000001ReGSQB0; first error: ENTITY_IS_DELETED, entity is deleted: []' + '\n\n' +
'(System Code)\n\n\n\n';

ExceptionData exData = ExceptionEmailParser.parse(emailBody);

System.assertEquals('0050R000001t3Br/00D0R000000DUxZ', exData.userOrg());
System.assertEquals('System.DmlException', exData.className());
System.assertEquals('Delete failed. First exception on row 5 with id b4r2E000001ReGSQB0; first error: ENTITY_IS_DELETED, entity is deleted: []', exData.message());
System.assertEquals('Source organization: 00D0R000000DUxZ (null)\nFailed to process batch for class \'ccrz.ccProductIndexCleanupJob\' for job id \'8061F00001btixO\'', exData.context());
}

@isTest
public static void testParseNoCausedBy() {
String emailBody =
'Apex script unhandled exception by user/organization: 0050R000001t3Br/00D0R000000DUxZ' + '\n\n' +
'Failed to process batch for class \'G2Crowd.CalculateAccountAveragesBatch\' for job id \'8061F00001btixO\'';

ExceptionData exData = ExceptionEmailParser.parse(emailBody);

System.assertEquals('0050R000001t3Br/00D0R000000DUxZ', exData.userOrg());
System.assertEquals('Failed to process batch for class \'G2Crowd.CalculateAccountAveragesBatch\' for job id \'8061F00001btixO\'', exData.context());
}

@isTest
public static void testParseUnknownFormat() {
String emailBody =
'Failed to process batch for class \'G2Crowd.CalculateAccountAveragesBatch\' for job id \'8061F00001btixO\'';

ExceptionData exData = ExceptionEmailParser.parse(emailBody);

System.assertEquals(null, exData.userOrg());
System.assertEquals('Unknown Error', exData.message());
System.assertEquals('Failed to process batch for class \'G2Crowd.CalculateAccountAveragesBatch\' for job id \'8061F00001btixO\'', exData.context());
}

@isTest
Expand Down Expand Up @@ -59,7 +116,7 @@ public class ExceptionEmailParserTest {
String emailBody = 'caused by: System.ListException: List index out of bounds: 2';
String result = ExceptionEmailParser.parseClassName(emailBody);
System.assertEquals('System.ListException', result);
}
}

@isTest
public static void testParseMessage() {
Expand All @@ -72,7 +129,7 @@ public class ExceptionEmailParserTest {
String emailBody = 'caused by: System.ListException: List index out of bounds: 2';
String result = ExceptionEmailParser.parseMessage(emailBody);
System.assertEquals('List index out of bounds: 2', result);
}
}

@isTest
public static void testParseFileName() {
Expand All @@ -81,20 +138,14 @@ public class ExceptionEmailParserTest {
}

@isTest
public static void testParseContext() {
String result = ExceptionEmailParser.parseContext(emailBody);
System.assertEquals('ContactEx: execution of AfterInsert', result);
}

@isTest
public static void testParseLine() {
Integer result = ExceptionEmailParser.parseLine(emailBody);
public static void testParseLineno() {
Integer result = ExceptionEmailParser.parseLineno(emailBody);
System.assertEquals(3, result);
}

@isTest
public static void testParseColumn() {
Integer result = ExceptionEmailParser.parseColumn(emailBody);
public static void testParseColno() {
Integer result = ExceptionEmailParser.parseColno(emailBody);
System.assertEquals(1, result);
}
}