package com.linkedin.thirdeye.anomaly.alert.util; import com.linkedin.thirdeye.detector.email.filter.DummyAlertFilter; import com.linkedin.thirdeye.detector.email.filter.PrecisionRecallEvaluator; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TimeZone; import org.apache.commons.lang3.StringUtils; import org.apache.commons.mail.HtmlEmail; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.base.Throwables; import com.linkedin.thirdeye.anomaly.SmtpConfiguration; import com.linkedin.thirdeye.anomaly.ThirdEyeAnomalyConfiguration; import com.linkedin.thirdeye.anomaly.alert.AlertTaskRunner; import com.linkedin.thirdeye.anomaly.alert.v2.AlertTaskRunnerV2; import com.linkedin.thirdeye.anomalydetection.context.AnomalyFeedback; import com.linkedin.thirdeye.api.DimensionMap; import com.linkedin.thirdeye.client.DAORegistry; import com.linkedin.thirdeye.datalayer.bao.MergedAnomalyResultManager; import com.linkedin.thirdeye.datalayer.bao.MetricConfigManager; import com.linkedin.thirdeye.datalayer.dto.AlertConfigDTO; import com.linkedin.thirdeye.datalayer.dto.MergedAnomalyResultDTO; import com.linkedin.thirdeye.datalayer.dto.MetricConfigDTO; import com.linkedin.thirdeye.util.ThirdEyeUtils; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateExceptionHandler; public class AnomalyReportGenerator { private static final Logger LOG = LoggerFactory.getLogger(AnomalyReportGenerator.class); private static final AnomalyReportGenerator INSTANCE = new AnomalyReportGenerator(); private static final String DATE_PATTERN = "MMM dd, HH:mm"; private static final String MULTIPLE_ANOMALIES_EMAIL_TEMPLATE = "multiple-anomalies-email-template.ftl"; public static AnomalyReportGenerator getInstance() { return INSTANCE; } MergedAnomalyResultManager anomalyResultManager = DAORegistry.getInstance().getMergedAnomalyResultDAO(); MetricConfigManager metricConfigManager = DAORegistry.getInstance().getMetricConfigDAO(); public List<MergedAnomalyResultDTO> getAnomaliesForDatasets(List<String> collections, long startTime, long endTime) { List<MergedAnomalyResultDTO> anomalies = new ArrayList<>(); for (String collection : collections) { anomalies .addAll(anomalyResultManager.findByCollectionTime(collection, startTime, endTime, false)); } return anomalies; } public List<MergedAnomalyResultDTO> getAnomaliesForMetrics(List<String> metrics, long startTime, long endTime) { List<MergedAnomalyResultDTO> anomalies = new ArrayList<>(); LOG.info("fetching anomalies for metrics : " + metrics); for (String metric : metrics) { List<MetricConfigDTO> metricConfigDTOList = metricConfigManager.findByMetricName(metric); for (MetricConfigDTO metricConfigDTO : metricConfigDTOList) { List<MergedAnomalyResultDTO> results = anomalyResultManager .findByCollectionMetricTime(metricConfigDTO.getDataset(), metric, startTime, endTime, false); LOG.info("Found {} result for metric {}", results.size(), metric); anomalies.addAll(results); } } return anomalies; } public void buildReport(List<MergedAnomalyResultDTO> anomalies, ThirdEyeAnomalyConfiguration configuration, AlertConfigDTO alertConfig) { buildReport(anomalies, configuration, alertConfig.getRecipients(), alertConfig.getFromAddress(), alertConfig.getName()); } public void buildReport(List<MergedAnomalyResultDTO> anomalies, ThirdEyeAnomalyConfiguration configuration, String recipients, String fromAddress, String alertConfigName) { String subject = "Thirdeye Alert : " + alertConfigName; long startTime = System.currentTimeMillis(); long endTime = 0; for (MergedAnomalyResultDTO anomaly : anomalies) { if (anomaly.getStartTime() < startTime) { startTime = anomaly.getStartTime(); } if (anomaly.getEndTime() > endTime) { endTime = anomaly.getEndTime(); } } buildReport(startTime, endTime, anomalies, subject, configuration, false, recipients, fromAddress, alertConfigName, false); } public void buildReport(long startTime, long endTime, List<MergedAnomalyResultDTO> anomalies, String subject, ThirdEyeAnomalyConfiguration configuration, boolean includeSentAnomaliesOnly, String emailRecipients, String fromEmail, String alertConfigName, boolean includeSummary) { if (anomalies == null || anomalies.size() == 0) { LOG.info("No anomalies found to send email, please check the parameters.. exiting"); } else { DateTimeZone timeZone = DateTimeZone.forTimeZone(AlertTaskRunnerV2.DEFAULT_TIME_ZONE); Set<String> metrics = new HashSet<>(); List<AnomalyReportDTO> anomalyReportDTOList = new ArrayList<>(); List<String> anomalyIds = new ArrayList<>(); Set<String> datasets = new HashSet<>(); for (MergedAnomalyResultDTO anomaly : anomalies) { metrics.add(anomaly.getMetric()); datasets.add(anomaly.getCollection()); AnomalyFeedback feedback = anomaly.getFeedback(); String feedbackVal = getFeedbackValue(feedback); AnomalyReportDTO anomalyReportDTO = new AnomalyReportDTO(String.valueOf(anomaly.getId()), getAnomalyURL(anomaly, configuration.getDashboardHost()), ThirdEyeUtils.getRoundedValue(anomaly.getAvgBaselineVal()), ThirdEyeUtils.getRoundedValue(anomaly.getAvgCurrentVal()), getDimensionsList(anomaly.getDimensions()), getTimeDiffInHours(anomaly.getStartTime(), anomaly.getEndTime()), // duration feedbackVal, anomaly.getFunction().getFunctionName(), ThirdEyeUtils.getRoundedValue(anomaly.getWeight() * 100) + "%", getLiftDirection(anomaly.getWeight()), anomaly.getMetric(), getDateString(anomaly.getStartTime(), timeZone), getDateString(anomaly.getEndTime(), timeZone), getTimezoneString(timeZone) ); // include notified alerts only in the email if (includeSentAnomaliesOnly) { if (anomaly.isNotified()) { anomalyReportDTOList.add(anomalyReportDTO); anomalyIds.add(anomalyReportDTO.getAnomalyId()); } } else { anomalyReportDTOList.add(anomalyReportDTO); anomalyIds.add(anomalyReportDTO.getAnomalyId()); } } PrecisionRecallEvaluator precisionRecallEvaluator = new PrecisionRecallEvaluator(new DummyAlertFilter(), anomalies); HtmlEmail email = new HtmlEmail(); DataReportHelper.DateFormatMethod dateFormatMethod = new DataReportHelper.DateFormatMethod(timeZone); Map<String, Object> templateData = new HashMap<>(); templateData.put("datasets", Joiner.on(", ").join(datasets)); templateData.put("timeZone", getTimezoneString(timeZone)); templateData.put("dateFormat", dateFormatMethod); templateData.put("startTime", getDateString(startTime, timeZone)); templateData.put("endTime", getDateString(endTime, timeZone)); templateData.put("anomalyCount", anomalies.size()); templateData.put("metricsCount", metrics.size()); templateData.put("notifiedCount", precisionRecallEvaluator.getTotalReports()); templateData.put("feedbackCount", precisionRecallEvaluator.getTotalResponses()); templateData.put("trueAlertCount", precisionRecallEvaluator.getQualifiedTrueAnomaly()); templateData.put("falseAlertCount", precisionRecallEvaluator.getFalseAlarm()); templateData.put("nonActionableCount", precisionRecallEvaluator.getQualifiedTrueAnomalyNotActionable()); templateData.put("anomalyDetails", anomalyReportDTOList); templateData.put("alertConfigName", alertConfigName); templateData.put("includeSummary", includeSummary); templateData.put("reportGenerationTimeMillis", System.currentTimeMillis()); templateData.put("dashboardHost", configuration.getDashboardHost()); templateData.put("anomalyIds", Joiner.on(",").join(anomalyIds)); if(precisionRecallEvaluator.getTotalResponses() > 0) { templateData.put("precision", precisionRecallEvaluator.getPrecisionInResponse()); templateData.put("recall", precisionRecallEvaluator.getRecall()); templateData.put("falseNegative", precisionRecallEvaluator.getFalseNegativeRate()); } String imgPath = null; String cid = ""; if (anomalyReportDTOList.size() == 1) { AnomalyReportDTO singleAnomaly = anomalyReportDTOList.get(0); subject = subject + " - " + singleAnomaly.getMetric(); try { imgPath = EmailScreenshotHelper.takeGraphScreenShot(singleAnomaly.getAnomalyId(), configuration); if (StringUtils.isNotBlank(imgPath)) { cid = email.embed(new File(imgPath)); } } catch (Exception e) { LOG.error("Exception while embedding screenshot for anomaly {}", singleAnomaly.getAnomalyId(), e); } } templateData.put("cid", cid); buildEmailTemplateAndSendAlert(templateData, configuration.getSmtpConfiguration(), subject, emailRecipients, fromEmail, email); if (StringUtils.isNotBlank(imgPath)) { try { Files.deleteIfExists(new File(imgPath).toPath()); } catch (IOException e) { LOG.error("Exception in deleting screenshot {}", imgPath, e); } } } } void buildEmailTemplateAndSendAlert(Map<String, Object> paramMap, SmtpConfiguration smtpConfiguration, String subject, String emailRecipients, String fromEmail, HtmlEmail email) { if (Strings.isNullOrEmpty(fromEmail)) { throw new IllegalArgumentException("Invalid sender's email"); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (Writer out = new OutputStreamWriter(baos, AlertTaskRunner.CHARSET)) { Configuration freemarkerConfig = new Configuration(Configuration.VERSION_2_3_21); freemarkerConfig.setClassForTemplateLoading(getClass(), "/com/linkedin/thirdeye/detector"); freemarkerConfig.setDefaultEncoding(AlertTaskRunner.CHARSET); freemarkerConfig.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); Template template = freemarkerConfig.getTemplate(MULTIPLE_ANOMALIES_EMAIL_TEMPLATE); template.process(paramMap, out); String alertEmailHtml = new String(baos.toByteArray(), AlertTaskRunner.CHARSET); EmailHelper.sendEmailWithHtml(email, smtpConfiguration, subject, alertEmailHtml, fromEmail, emailRecipients); } catch (Exception e) { Throwables.propagate(e); } } private String getDateString(Long millis, DateTimeZone dateTimeZone) { String dateString = new DateTime(millis, dateTimeZone).toString(DATE_PATTERN); return dateString; } private String getTimeDiffInHours(long start, long end) { double duration = Double.valueOf((end - start) / 1000) / 3600; String durationString = ThirdEyeUtils.getRoundedValue(duration) + ((duration == 1) ? (" hour") : (" hours")); return durationString; } private List<String> getDimensionsList(DimensionMap dimensionMap) { List<String> dimensionsList = new ArrayList<>(); if (dimensionMap != null && !dimensionMap.isEmpty()) { for (Entry<String, String> entry : dimensionMap.entrySet()) { dimensionsList.add(entry.getKey() + " : " + entry.getValue()); } } return dimensionsList; } private boolean getLiftDirection(double lift) { return lift < 0 ? false : true; } private String getTimezoneString(DateTimeZone dateTimeZone) { TimeZone tz = TimeZone.getTimeZone(dateTimeZone.getID()); return tz.getDisplayName(true, 0); } private String getFeedbackValue(AnomalyFeedback feedback) { String feedbackVal = "Not Resolved"; if (feedback != null && feedback.getFeedbackType() != null) { switch (feedback.getFeedbackType()) { case ANOMALY: feedbackVal = "Resolved (Confirmed Anomaly)"; break; case NOT_ANOMALY: feedbackVal = "Resolved (False Alarm)"; break; case ANOMALY_NO_ACTION: feedbackVal = "Not Actionable"; break; case NO_FEEDBACK: default: break; } } return feedbackVal; } private String getAnomalyURL(MergedAnomalyResultDTO anomalyResultDTO, String dashboardUrl) { String urlPart = "/thirdeye#investigate?anomalyId="; return dashboardUrl + urlPart; } @JsonIgnoreProperties(ignoreUnknown = true) public static class AnomalyReportDTO { String metric; String startDateTime; String lift; boolean positiveLift; String feedback; String anomalyId; String anomalyURL; String currentVal; String baselineVal; List<String> dimensions; String function; String duration; String startTime; String endTime; String timezone; public AnomalyReportDTO(String anomalyId, String anomalyURL, String baselineVal, String currentVal, List<String> dimensions, String duration, String feedback, String function, String lift, boolean positiveLift, String metric, String startTime, String endTime, String timezone) { this.anomalyId = anomalyId; this.anomalyURL = anomalyURL; this.baselineVal = baselineVal; this.currentVal = currentVal; this.dimensions = dimensions; this.duration = duration; this.feedback = feedback; this.function = function; this.lift = lift; this.positiveLift = positiveLift; this.metric = metric; this.startDateTime = startTime; this.endTime = endTime; this.timezone = timezone; } public String getBaselineVal() { return baselineVal; } public void setBaselineVal(String baselineVal) { this.baselineVal = baselineVal; } public String getCurrentVal() { return currentVal; } public void setCurrentVal(String currentVal) { this.currentVal = currentVal; } public List<String> getDimensions() { return dimensions; } public void setDimensions(List<String> dimensions) { this.dimensions = dimensions; } public String getDuration() { return duration; } public void setDuration(String duration) { this.duration = duration; } public String getFunction() { return function; } public void setFunction(String function) { this.function = function; } public String getAnomalyId() { return anomalyId; } public void setAnomalyId(String anomalyId) { this.anomalyId = anomalyId; } public String getFeedback() { return feedback; } public void setFeedback(String feedback) { this.feedback = feedback; } public String getLift() { return lift; } public void setLift(String lift) { this.lift = lift; } public boolean isPositiveLift() { return positiveLift; } public void setPositiveLift(boolean positiveLift) { this.positiveLift = positiveLift; } public String getMetric() { return metric; } public void setMetric(String metric) { this.metric = metric; } public String getStartDateTime() { return startDateTime; } public void setStartDateTime(String startDateTime) { this.startDateTime = startDateTime; } public String getAnomalyURL() { return anomalyURL; } public void setAnomalyURL(String anomalyURL) { this.anomalyURL = anomalyURL; } public String getStartTime() { return startTime; } public void setStartTime(String startTime) { this.startTime = startTime; } public String getEndTime() { return endTime; } public void setEndTime(String endTime) { this.endTime = endTime; } public String getTimezone() { return timezone; } public void setTimezone(String timezone) { this.timezone = timezone; } } }