Skip to content

Commit

Permalink
Better exception email parsing (#25)
Browse files Browse the repository at this point in the history
* fix: better exception email parsing

* fix: handle multi-line user/org info; refactor parser to use switch statement
  • Loading branch information
waltjones authored Oct 12, 2020
1 parent 08db6cb commit a09fe30
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 51 deletions.
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) {
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);
}
}

0 comments on commit a09fe30

Please sign in to comment.