Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,27 @@ public IncomingEmailResult processIncomingEmail(String from, String subject, Str
}
}
if (ticket == null) {
ticket = createTicketForIncoming(sender, subject, body);
List<String> incomingTraces = new ArrayList<>();
incomingTraces.addAll(StackTraceExtractor.extractStackTraces(body));
incomingTraces.addAll(StackTraceExtractor.extractStackTracesFromAttachments(attachments));

if (!incomingTraces.isEmpty()) {
Ticket duplicateTicket = findExistingTicketWithSameStackTrace(sender, incomingTraces);
if (duplicateTicket != null) {
ticket = duplicateTicket;
escalateTicket(ticket);
if ("Closed".equalsIgnoreCase(ticket.status) || "Resolved".equalsIgnoreCase(ticket.status)) {
ticket.status = "Open";
ticket.persist();
}
}
}

if (ticket == null) {
return IncomingEmailResult.ignored();
ticket = createTicketForIncoming(sender, subject, body);
if (ticket == null) {
return IncomingEmailResult.ignored();
}
}
}
Message message = new Message();
Expand Down Expand Up @@ -192,6 +210,66 @@ private void assignCompanyTams(Ticket ticket) {
}
}

private Ticket findExistingTicketWithSameStackTrace(User sender, List<String> incomingTraces) {
if (incomingTraces.isEmpty()) {
return null;
}
Company company = companyForSender(sender);
if (company == null) {
return null;
}

Set<String> normalizedIncoming = new HashSet<>();
for (String trace : incomingTraces) {
normalizedIncoming.add(StackTraceExtractor.normalizeStackTrace(trace));
}

List<Message> companyMessages = Message
.list("select m from Message m join m.ticket t where t.company = ?1 order by t.id desc", company);
for (Message m : companyMessages) {
if (m.body != null) {
List<String> traces = StackTraceExtractor.extractStackTraces(m.body);
for (String trace : traces) {
if (normalizedIncoming.contains(StackTraceExtractor.normalizeStackTrace(trace))) {
return m.ticket;
}
Comment on lines +227 to +235
}
}
}

List<Attachment> companyAttachments = Attachment.list(
"select a from Attachment a join a.message m join m.ticket t where t.company = ?1 order by t.id desc",
company);
for (Attachment a : companyAttachments) {
if (a.data != null) {
String text = new String(a.data, java.nio.charset.StandardCharsets.UTF_8);
List<String> traces = StackTraceExtractor.extractStackTraces(text);
Comment on lines +240 to +246
for (String trace : traces) {
if (normalizedIncoming.contains(StackTraceExtractor.normalizeStackTrace(trace))) {
return a.message.ticket;
}
}
}
}

return null;
}

private void escalateTicket(Ticket ticket) {
if (ticket == null || ticket.companyEntitlement == null) {
return;
}
CompanyEntitlement escalatedEntitlement = CompanyEntitlement
.find("company = ?1 and entitlement = ?2 and supportLevel.name = ?3", ticket.company,
ticket.companyEntitlement.entitlement, "Escalate")
.firstResult();
if (escalatedEntitlement != null) {
ticket.companyEntitlement = escalatedEntitlement;
ticket.persist();
LOGGER.infof("Ticket '%s' escalated to Escalate level due to duplicate stack trace", ticket.name);
}
}

public record IncomingEmailResult(boolean processed, String ticketName) {

static IncomingEmailResult processed(String ticketName) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Eclipse Public License - v 2.0
*
* THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
* PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
*/

package ai.mnemosyne_systems.service;

import ai.mnemosyne_systems.model.Attachment;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class StackTraceExtractor {

public static List<String> extractStackTraces(String text) {
List<String> traces = new ArrayList<>();
if (text == null || text.isBlank()) {
return traces;
}
String[] lines = text.split("\\R");
int i = 0;
while (i < lines.length) {
String line = lines[i].trim();
if (isFrameLine(line)) {
List<String> block = new ArrayList<>();
// Look backward for exception/header line
String header = null;
if (i > 0) {
String prev = lines[i - 1].trim();
if (!isFrameLine(prev) && (prev.contains("Exception") || prev.contains("Error")
|| prev.contains("Throwable") || prev.contains("at "))) {
header = prev;
}
Comment on lines +29 to +36
}
if (header != null) {
block.add(header);
}

int frameCount = 0;
while (i < lines.length) {
String curr = lines[i].trim();
if (isFrameLine(curr) || curr.startsWith("Caused by:")
|| curr.matches("^\\s*\\.\\.\\.\\s+\\d+\\s+more\\s*$")) {
block.add(curr);
if (isFrameLine(curr)) {
frameCount++;
}
i++;
} else {
break;
}
}
if (frameCount >= 2) {
traces.add(String.join("\n", block));
}
} else {
i++;
}
}
return traces;
}

public static List<String> extractStackTracesFromAttachments(List<Attachment> attachments) {
List<String> traces = new ArrayList<>();
if (attachments == null) {
return traces;
}
for (Attachment attachment : attachments) {
if (attachment.data != null) {
String text = new String(attachment.data, StandardCharsets.UTF_8);
traces.addAll(extractStackTraces(text));
}
}
Comment on lines +66 to +76
return traces;
}

public static String normalizeStackTrace(String trace) {
if (trace == null) {
return "";
}
// Remove line numbers like :123) with )
String withoutLineNumbers = trace.replaceAll(":\\d+\\)", ")");
// Replace all whitespace with a single space to ignore formatting differences
return withoutLineNumbers.replaceAll("\\s+", " ").trim();
}

private static boolean isFrameLine(String line) {
return line.startsWith("at ") && line.contains("(") && line.contains(")");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,58 @@ void incomingEmailWithoutTicketSubjectCreatesTicket() {
Assertions.assertTrue(saved.ticket.name.contains("-"));
}

@Test
void incomingEmailWithDuplicateStackTraceUpdatesExistingTicketAndEscalates() {
mailbox.clear();
ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user");
Long companyId = ensureCompany("Incoming Deduplication Co");
ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai");

ensureEscalateEntitlementSupport(companyId);

String stackTrace = "java.lang.NullPointerException: test exception\n"
+ " at com.example.TestService.execute(TestService.java:23)\n"
+ " at com.example.TestService.run(TestService.java:10)";

String body1 = "We hit this error:\n" + stackTrace;

RestAssured.given().contentType("multipart/form-data").multiPart("from", "user@mnemosyne-systems.ai")
.multiPart("subject", "Crash report").multiPart("body", body1).post("/mail/incoming").then()
.statusCode(200);

Message saved1 = findMessageByBody(body1);
Assertions.assertNotNull(saved1);
Ticket ticket = saved1.ticket;
Assertions.assertNotNull(ticket);

Assertions.assertEquals("Normal", ticket.companyEntitlement.supportLevel.name);

setTicketStatus(ticket.id, "Closed");

String body2 = "Help, it happened again!\n" + stackTrace;
RestAssured.given().contentType("multipart/form-data").multiPart("from", "user@mnemosyne-systems.ai")
.multiPart("subject", "Second crash report").multiPart("body", body2).post("/mail/incoming").then()
.statusCode(200);

Message saved2 = findMessageByBody(body2);
Assertions.assertNotNull(saved2);
Assertions.assertEquals(ticket.id, saved2.ticket.id);

Ticket refreshed = refreshedTicket(ticket.id);
Assertions.assertEquals("Open", refreshed.status);
Assertions.assertEquals("Escalate", refreshed.companyEntitlement.supportLevel.name);
}

@Transactional
void ensureEscalateEntitlementSupport(Long companyId) {
Company company = Company.findById(companyId);
Entitlement entitlement = ensureEntitlement("Starter", "Email support");
Level normal = ensureLevel("Normal", "Normal response", 1440, "White");
Level escalate = ensureLevel("Escalate", "Escalate response", 120, "Yellow");
ensureCompanyEntitlement(company, entitlement, normal);
Comment on lines +779 to +781
ensureCompanyEntitlement(company, entitlement, escalate);
}

@Test
void incomingEmailIgnoresUnknownOrMismatchedFrom() {
mailbox.clear();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Eclipse Public License - v 2.0
*
* THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
* PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
*/

package ai.mnemosyne_systems.service;

import ai.mnemosyne_systems.model.Attachment;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class StackTraceExtractorTest {

@Test
public void testExtractStackTracesFromText() {
String body = "Hey developer,\n" + "I encountered the following issue in production:\n\n"
+ "java.lang.NullPointerException: Cannot invoke \"String.trim()\" because \"body\" is null\n"
+ " at ai.mnemosyne_systems.service.IncomingEmailService.processIncomingEmail(IncomingEmailService.java:44)\n"
+ " at ai.mnemosyne_systems.resource.IncomingEmailResource.process(IncomingEmailResource.java:52)\n"
+ "\n" + "Please fix this ASAP!";

List<String> traces = StackTraceExtractor.extractStackTraces(body);
Assertions.assertEquals(1, traces.size());

String trace = traces.get(0);
Assertions.assertTrue(trace.contains("java.lang.NullPointerException"));
Assertions.assertTrue(trace.contains("processIncomingEmail"));
Assertions.assertTrue(trace.contains("process"));
}

@Test
public void testExtractStackTracesFromAttachments() {
Attachment attachment = new Attachment();
attachment.name = "error.log";
attachment.mimeType = "text/plain";
attachment.data = ("Uncaught exception:\n" + "java.lang.RuntimeException: Something went wrong\n"
+ " at ai.mnemosyne_systems.service.SomeService.doWork(SomeService.java:12)\n"
+ " at ai.mnemosyne_systems.service.SomeService.start(SomeService.java:8)\n"
+ "Caused by: java.io.IOException: Disk full\n"
+ " at java.base/java.io.FileOutputStream.writeBytes(Native Method)\n" + " ... 3 more")
.getBytes(StandardCharsets.UTF_8);

List<String> traces = StackTraceExtractor
.extractStackTracesFromAttachments(Collections.singletonList(attachment));
Assertions.assertEquals(1, traces.size());

String trace = traces.get(0);
Assertions.assertTrue(trace.contains("java.lang.RuntimeException"));
Assertions.assertTrue(trace.contains("doWork"));
Assertions.assertTrue(trace.contains("Disk full"));
}

@Test
public void testNormalizeStackTrace() {
String trace1 = "java.lang.NullPointerException\n"
+ " at ai.mnemosyne_systems.service.IncomingEmailService.processIncomingEmail(IncomingEmailService.java:44)\n"
+ " at ai.mnemosyne_systems.resource.IncomingEmailResource.process(IncomingEmailResource.java:52)";

String trace2 = "java.lang.NullPointerException\n"
+ " at ai.mnemosyne_systems.service.IncomingEmailService.processIncomingEmail(IncomingEmailService.java:46)\n"
+ " at ai.mnemosyne_systems.resource.IncomingEmailResource.process(IncomingEmailResource.java:53)";

String normalized1 = StackTraceExtractor.normalizeStackTrace(trace1);
String normalized2 = StackTraceExtractor.normalizeStackTrace(trace2);

Assertions.assertEquals(normalized1, normalized2);
Assertions.assertFalse(normalized1.contains("44"));
Assertions.assertFalse(normalized2.contains("46"));
}

@Test
public void testNoStackTrace() {
String body = "Hello, I have a question about how to set up SSO on the business plan.";
List<String> traces = StackTraceExtractor.extractStackTraces(body);
Assertions.assertTrue(traces.isEmpty());
}
}
Loading