package com.linkedin.thirdeye.anomaly.alert.util; import com.google.common.cache.LoadingCache; import com.google.common.collect.Lists; import com.google.common.collect.Multimap; import com.linkedin.thirdeye.anomaly.SmtpConfiguration; import com.linkedin.thirdeye.anomaly.ThirdEyeAnomalyConfiguration; import com.linkedin.thirdeye.anomaly.alert.v2.AlertTaskRunnerV2; import com.linkedin.thirdeye.client.ThirdEyeCacheRegistry; import com.linkedin.thirdeye.client.cache.QueryCache; import com.linkedin.thirdeye.constant.MetricAggFunction; import com.linkedin.thirdeye.dashboard.Utils; import com.linkedin.thirdeye.dashboard.views.contributor.ContributorViewHandler; import com.linkedin.thirdeye.dashboard.views.contributor.ContributorViewRequest; import com.linkedin.thirdeye.dashboard.views.contributor.ContributorViewResponse; import com.linkedin.thirdeye.datalayer.pojo.AlertConfigBean; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.mail.DefaultAuthenticator; import org.apache.commons.mail.EmailException; import org.apache.commons.mail.HtmlEmail; import org.jfree.chart.JFreeChart; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.quartz.JobExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.linkedin.thirdeye.api.TimeGranularity; import com.linkedin.thirdeye.client.DAORegistry; import com.linkedin.thirdeye.client.MetricExpression; import com.linkedin.thirdeye.client.comparison.TimeOnTimeComparisonHandler; import com.linkedin.thirdeye.client.comparison.TimeOnTimeComparisonRequest; import com.linkedin.thirdeye.client.comparison.TimeOnTimeComparisonResponse; import com.linkedin.thirdeye.datalayer.bao.DatasetConfigManager; import com.linkedin.thirdeye.datalayer.dto.DataCompletenessConfigDTO; import com.linkedin.thirdeye.datalayer.dto.DatasetConfigDTO; import com.linkedin.thirdeye.datalayer.dto.EmailConfigurationDTO; import com.linkedin.thirdeye.datalayer.dto.RawAnomalyResultDTO; import com.linkedin.thirdeye.detector.email.AnomalyGraphGenerator; /** * Stateless class to provide util methods to help build anomaly report */ public abstract class EmailHelper { private static final Logger LOG = LoggerFactory.getLogger(EmailHelper.class); private static final String PNG = ".png"; private static final String EMAIL_REPORT_CHART_PREFIX = "email_report_chart_"; private static final long HOUR_MILLIS = TimeUnit.HOURS.toMillis(1); private static final long DAY_MILLIS = TimeUnit.DAYS.toMillis(1); private static final long WEEK_MILLIS = TimeUnit.DAYS.toMillis(7); private static final int MINIMUM_GRAPH_WINDOW_HOURS = 24; private static final int MINIMUM_GRAPH_WINDOW_DAYS = 7; public static final String EMAIL_ADDRESS_SEPARATOR = ","; private static final DAORegistry DAO_REGISTRY = DAORegistry.getInstance(); private static final ThirdEyeCacheRegistry CACHE_REGISTRY_INSTANCE = ThirdEyeCacheRegistry.getInstance(); private static final LoadingCache<String, Long> collectionMaxDataTimeCache = CACHE_REGISTRY_INSTANCE.getCollectionMaxDataTimeCache(); private static final QueryCache queryCache = CACHE_REGISTRY_INSTANCE.getQueryCache(); private static final DatasetConfigManager datasetConfigManager = DAO_REGISTRY.getDatasetConfigDAO(); private EmailHelper() { } public static String writeTimeSeriesChart(final EmailConfigurationDTO config, TimeOnTimeComparisonHandler timeOnTimeComparisonHandler, final DateTime now, final DateTime then, final String collection, final Map<RawAnomalyResultDTO, String> anomaliesWithLabels) throws JobExecutionException { try { int windowSize = config.getWindowSize(); TimeUnit windowUnit = config.getWindowUnit(); long windowMillis = windowUnit.toMillis(windowSize); // TODO provide a way for email reports to specify desired graph granularity. DatasetConfigDTO datasetConfig = CACHE_REGISTRY_INSTANCE.getDatasetConfigCache().get(collection); TimeGranularity dataGranularity = datasetConfig.bucketTimeGranularity(); TimeOnTimeComparisonResponse chartData = getData(timeOnTimeComparisonHandler, config, then, now, WEEK_MILLIS, dataGranularity); AnomalyGraphGenerator anomalyGraphGenerator = AnomalyGraphGenerator.getInstance(); JFreeChart chart = anomalyGraphGenerator .createChart(chartData, dataGranularity, windowMillis, anomaliesWithLabels); String chartFilePath = EMAIL_REPORT_CHART_PREFIX + config.getId() + PNG; LOG.info("Writing chart to {}", chartFilePath); anomalyGraphGenerator.writeChartToFile(chart, chartFilePath); return chartFilePath; } catch (Exception e) { throw new JobExecutionException(e); } } /** * Generate and send request to retrieve chart data. If the request window is too small, the graph * data retrieved has default window sizes based on time granularity and ending at the defined * endpoint: <br/> <ul> <li>DAYS: 7</li> <li>HOURS: 24</li> </ul> * * @param bucketGranularity * * @throws JobExecutionException */ public static TimeOnTimeComparisonResponse getData( TimeOnTimeComparisonHandler timeOnTimeComparisonHandler, EmailConfigurationDTO config, DateTime start, final DateTime end, long baselinePeriodMillis, TimeGranularity bucketGranularity) throws JobExecutionException { start = calculateGraphDataStart(start, end, bucketGranularity); try { TimeOnTimeComparisonRequest comparisonRequest = new TimeOnTimeComparisonRequest(); comparisonRequest.setCollectionName(config.getCollection()); comparisonRequest.setBaselineStart(start.minus(baselinePeriodMillis)); comparisonRequest.setBaselineEnd(end.minus(baselinePeriodMillis)); comparisonRequest.setCurrentStart(start); comparisonRequest.setCurrentEnd(end); comparisonRequest.setEndDateInclusive(true); List<MetricExpression> metricExpressions = new ArrayList<>(); MetricExpression expression = new MetricExpression(config.getMetric(), config.getCollection()); metricExpressions.add(expression); comparisonRequest.setMetricExpressions(metricExpressions); comparisonRequest.setAggregationTimeGranularity(bucketGranularity); LOG.debug("Starting..."); TimeOnTimeComparisonResponse response = timeOnTimeComparisonHandler.handle(comparisonRequest); LOG.debug("Done!"); return response; } catch (Exception e) { throw new JobExecutionException(e); } } public static DateTime calculateGraphDataStart(DateTime start, DateTime end, TimeGranularity bucketGranularity) { TimeUnit unit = bucketGranularity.getUnit(); long minUnits; if (TimeUnit.DAYS.equals(unit)) { minUnits = MINIMUM_GRAPH_WINDOW_DAYS; } else if (TimeUnit.HOURS.equals(unit)) { minUnits = MINIMUM_GRAPH_WINDOW_HOURS; } else { // no need to do calculation, return input start; return start; } long currentUnits = unit.convert(end.getMillis() - start.getMillis(), TimeUnit.MILLISECONDS); if (currentUnits < minUnits) { LOG.info("Overriding config window size {} {} with minimum default of {}", currentUnits, unit, minUnits); start = end.minus(unit.toMillis(minUnits)); } return start; } public static void sendEmailWithTextBody(HtmlEmail email, SmtpConfiguration smtpConfigutation, String subject, String textBody, String fromAddress, String toAddress) throws EmailException { email.setTextMsg(textBody); sendEmail(smtpConfigutation, email, subject, fromAddress, toAddress); } public static void sendEmailWithHtml(HtmlEmail email, SmtpConfiguration smtpConfiguration, String subject, String htmlBody, String fromAddress, String toAddress) throws EmailException { email.setHtmlMsg(htmlBody); sendEmail(smtpConfiguration, email, subject, fromAddress, toAddress); } /** Sends email according to the provided config. */ private static void sendEmail(SmtpConfiguration config, HtmlEmail email, String subject, String fromAddress, String toAddress) throws EmailException { if (config != null) { email.setSubject(subject); LOG.info("Sending email to {}", toAddress); email.setHostName(config.getSmtpHost()); email.setSmtpPort(config.getSmtpPort()); if (config.getSmtpUser() != null && config.getSmtpPassword() != null) { email.setAuthenticator( new DefaultAuthenticator(config.getSmtpUser(), config.getSmtpPassword())); email.setSSLOnConnect(true); } email.setFrom(fromAddress); for (String address : toAddress.split(EMAIL_ADDRESS_SEPARATOR)) { email.addTo(address); } email.send(); LOG.info("Email sent with subject [{}] to address [{}]!", subject, toAddress); } else { LOG.error("No email config provided for email with subject [{}]!", subject); } } public static ContributorViewResponse getContributorDataForDataReport(String collection, String metric, List<String> dimensions, AlertConfigBean.COMPARE_MODE compareMode, long offsetDelayMillis, boolean intraday) throws Exception { long baselineOffset = getBaselineOffset(compareMode); ContributorViewRequest request = new ContributorViewRequest(); request.setCollection(collection); List<MetricExpression> metricExpressions = Utils.convertToMetricExpressions(metric, MetricAggFunction.SUM, collection); request.setMetricExpressions(metricExpressions); long currentEnd = System.currentTimeMillis(); long maxDataTime = collectionMaxDataTimeCache.get(collection); if (currentEnd > maxDataTime) { currentEnd = maxDataTime; } // align to nearest hour currentEnd = (currentEnd - (currentEnd % HOUR_MILLIS)) - offsetDelayMillis; String aggTimeGranularity = "HOURS"; long currentStart = currentEnd - DAY_MILLIS; // intraday option if (intraday) { DateTimeZone timeZone = DateTimeZone.forTimeZone(AlertTaskRunnerV2.DEFAULT_TIME_ZONE); DateTime endDate = new DateTime(currentEnd, timeZone); DateTime intraDayStartTime = new DateTime(endDate.toString().split("T")[0], timeZone); if (intraDayStartTime.getMillis() != currentEnd) { currentStart = intraDayStartTime.getMillis(); } } DatasetConfigDTO datasetConfigDTO = datasetConfigManager.findByDataset(collection); if (datasetConfigDTO != null && TimeUnit.DAYS.equals(datasetConfigDTO.bucketTimeGranularity().getUnit())) { aggTimeGranularity = datasetConfigDTO.bucketTimeGranularity().getUnit().name(); currentEnd = currentEnd - (currentEnd % DAY_MILLIS); currentStart = currentEnd - WEEK_MILLIS; } long baselineStart = currentStart - baselineOffset ; long baselineEnd = currentEnd - baselineOffset; String timeZone = datasetConfigDTO.getTimezone(); request.setBaselineStart(new DateTime(baselineStart, DateTimeZone.forID(timeZone))); request.setBaselineEnd(new DateTime(baselineEnd, DateTimeZone.forID(timeZone))); request.setCurrentStart(new DateTime(currentStart, DateTimeZone.forID(timeZone))); request.setCurrentEnd(new DateTime(currentEnd, DateTimeZone.forID(timeZone))); request.setTimeGranularity(Utils.getAggregationTimeGranularity(aggTimeGranularity, collection)); request.setGroupByDimensions(dimensions); ContributorViewHandler handler = new ContributorViewHandler(queryCache); return handler.process(request); } public static ContributorViewResponse getContributorDataForDataReport(String collection, String metric, List<String> dimensions) throws Exception { return getContributorDataForDataReport(collection, metric, dimensions, AlertConfigBean.COMPARE_MODE.WoW, 2 * 36_00_000, false); // add 2 hours delay } public static long getBaselineOffset(AlertConfigBean.COMPARE_MODE compareMode) { switch (compareMode) { case Wo2W: return 2 * WEEK_MILLIS; case Wo3W: return 3 * WEEK_MILLIS; case Wo4W: return 4 * WEEK_MILLIS; case WoW: default: return WEEK_MILLIS; } } public static void sendFailureEmailForScreenshot(String anomalyId, Throwable t, ThirdEyeAnomalyConfiguration thirdeyeConfig) throws JobExecutionException { HtmlEmail email = new HtmlEmail(); String subject = String .format("[ThirdEye Anomaly Detector] FAILED SCREENSHOT FOR ANOMALY ID=%s", anomalyId); String textBody = String .format("%sException:%s", anomalyId, ExceptionUtils.getStackTrace(t)); try { EmailHelper .sendEmailWithTextBody(email, thirdeyeConfig.getSmtpConfiguration(), subject, textBody, thirdeyeConfig.getFailureFromAddress(), thirdeyeConfig.getFailureToAddress()); } catch (EmailException e) { LOG.error("Exception in sending email for failed screenshot", e); } } public static void sendNotificationForDataIncomplete( Multimap<String, DataCompletenessConfigDTO> incompleteEntriesToNotify, ThirdEyeAnomalyConfiguration thirdeyeConfig) { HtmlEmail email = new HtmlEmail(); String subject = String.format("Data Completeness Checker Report"); StringBuilder textBody = new StringBuilder(); for (String dataset : incompleteEntriesToNotify.keySet()) { List<DataCompletenessConfigDTO> entries = Lists.newArrayList(incompleteEntriesToNotify.get(dataset)); textBody.append(String.format("\nDataset: %s\n", dataset)); for (DataCompletenessConfigDTO entry : entries) { textBody.append(String.format("%s ", entry.getDateToCheckInSDF())); } textBody.append("\n*******************************************************\n"); } LOG.info("Data Completeness Checker Report : Sending email to {} with subject {} and text {}", thirdeyeConfig.getFailureToAddress(), subject, textBody.toString()); try { EmailHelper.sendEmailWithTextBody(email, thirdeyeConfig.getSmtpConfiguration(), subject, textBody.toString(), thirdeyeConfig.getFailureFromAddress(), thirdeyeConfig.getFailureToAddress()); } catch (EmailException e) { LOG.error("Exception in sending email notification for incomplete datasets", e); } } }