diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt index 234653251..637f7096c 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt @@ -13,6 +13,8 @@ import org.opensearch.action.admin.indices.refresh.RefreshAction import org.opensearch.action.admin.indices.refresh.RefreshRequest import org.opensearch.action.bulk.BulkRequest import org.opensearch.action.bulk.BulkResponse +import org.opensearch.action.get.MultiGetItemResponse +import org.opensearch.action.get.MultiGetRequest import org.opensearch.action.index.IndexRequest import org.opensearch.action.search.SearchAction import org.opensearch.action.search.SearchRequest @@ -23,12 +25,15 @@ import org.opensearch.alerting.model.InputRunResults import org.opensearch.alerting.model.MonitorMetadata import org.opensearch.alerting.model.MonitorRunResult import org.opensearch.alerting.model.userErrorMessage +import org.opensearch.alerting.opensearchapi.convertToMap import org.opensearch.alerting.opensearchapi.suspendUntil import org.opensearch.alerting.script.DocumentLevelTriggerExecutionContext import org.opensearch.alerting.util.AlertingException import org.opensearch.alerting.util.IndexUtils import org.opensearch.alerting.util.defaultToPerExecutionAction import org.opensearch.alerting.util.getActionExecutionPolicy +import org.opensearch.alerting.util.parseSampleDocTags +import org.opensearch.alerting.util.printsSampleDocData import org.opensearch.alerting.workflow.WorkflowRunContext import org.opensearch.client.node.NodeClient import org.opensearch.cluster.metadata.IndexMetadata @@ -65,6 +70,7 @@ import org.opensearch.percolator.PercolateQueryBuilderExt import org.opensearch.search.SearchHit import org.opensearch.search.SearchHits import org.opensearch.search.builder.SearchSourceBuilder +import org.opensearch.search.fetch.subphase.FetchSourceContext import org.opensearch.search.sort.SortOrder import java.io.IOException import java.time.Instant @@ -84,6 +90,12 @@ class DocumentLevelMonitorRunner : MonitorRunner() { * Docs are fetched from the source index per shard and transformed.*/ val transformedDocs = mutableListOf>() + // Maps a finding ID to the concrete index name. + val findingIdToConcreteIndex = mutableMapOf() + + // Maps the docId to the doc source + val docIdToDocMap = mutableMapOf>() + override suspend fun runMonitor( monitor: Monitor, monitorCtx: MonitorRunnerExecutionContext, @@ -457,6 +469,13 @@ class DocumentLevelMonitorRunner : MonitorRunner() { error = monitorResult.error ?: triggerResult.error ) + if (printsSampleDocData(trigger) && triggerFindingDocPairs.isNotEmpty()) + getDocSources( + findingToDocPairs = findingToDocPairs, + monitorCtx = monitorCtx, + monitor = monitor + ) + val alerts = mutableListOf() val alertContexts = mutableListOf() triggerFindingDocPairs.forEach { @@ -469,12 +488,19 @@ class DocumentLevelMonitorRunner : MonitorRunner() { workflorwRunContext = workflowRunContext ) alerts.add(alert) + + val docId = alert.relatedDocIds.first().split("|").first() + val docSource = docIdToDocMap[docId]?.find { item -> + findingIdToConcreteIndex[alert.findingIds.first()] == item.index + }?.response?.convertToMap() + alertContexts.add( AlertContext( alert = alert, associatedQueries = alert.findingIds.flatMap { findingId -> monitorCtx.findingsToTriggeredQueries?.getOrDefault(findingId, emptyList()) ?: emptyList() - } + }, + sampleDocs = listOfNotNull(docSource) ) ) } @@ -565,6 +591,7 @@ class DocumentLevelMonitorRunner : MonitorRunner() { findingDocPairs.add(Pair(finding.id, it.key)) findings.add(finding) findingsToTriggeredQueries[finding.id] = triggeredQueries + findingIdToConcreteIndex[finding.id] = finding.index val findingStr = finding.toXContent(XContentBuilder.builder(XContentType.JSON.xContent()), ToXContent.EMPTY_PARAMS) @@ -1064,6 +1091,36 @@ class DocumentLevelMonitorRunner : MonitorRunner() { return numDocs >= maxNumDocsThreshold } + /** + * Performs an mGet request to retrieve the documents associated with findings. + * + * When possible, this will only retrieve the document fields that are specifically + * referenced for printing in the mustache template. + */ + private suspend fun getDocSources( + findingToDocPairs: List>, + monitorCtx: MonitorRunnerExecutionContext, + monitor: Monitor + ) { + val docFieldTags = parseSampleDocTags(monitor.triggers) + val request = MultiGetRequest() + findingToDocPairs.forEach { (_, docIdAndIndex) -> + val docIdAndIndexSplit = docIdAndIndex.split("|") + val docId = docIdAndIndexSplit[0] + val concreteIndex = docIdAndIndexSplit[1] + if (docId.isNotEmpty() && concreteIndex.isNotEmpty()) { + val docItem = MultiGetRequest.Item(concreteIndex, docId) + if (docFieldTags.isNotEmpty()) + docItem.fetchSourceContext(FetchSourceContext(true, docFieldTags.toTypedArray(), emptyArray())) + request.add(docItem) + } + } + val response = monitorCtx.client!!.suspendUntil { monitorCtx.client!!.multiGet(request, it) } + response.responses.forEach { item -> + docIdToDocMap.getOrPut(item.id) { mutableListOf() }.add(item) + } + } + /** * POJO holding information about each doc's concrete index, id, input index pattern/alias/datastream name * and doc source. A list of these POJOs would be passed to percolate query execution logic. diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/script/BucketLevelTriggerExecutionContext.kt b/alerting/src/main/kotlin/org/opensearch/alerting/script/BucketLevelTriggerExecutionContext.kt index b3478cfb8..d0e90fa24 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/script/BucketLevelTriggerExecutionContext.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/script/BucketLevelTriggerExecutionContext.kt @@ -45,11 +45,19 @@ data class BucketLevelTriggerExecutionContext( */ override fun asTemplateArg(): Map { val tempArg = super.asTemplateArg().toMutableMap() - tempArg["trigger"] = trigger.asTemplateArg() - tempArg["dedupedAlerts"] = dedupedAlerts.map { it.asTemplateArg() } - tempArg["newAlerts"] = newAlerts.map { it.asTemplateArg() } - tempArg["completedAlerts"] = completedAlerts.map { it.asTemplateArg() } - tempArg["results"] = results + tempArg[TRIGGER_FIELD] = trigger.asTemplateArg() + tempArg[DEDUPED_ALERTS_FIELD] = dedupedAlerts.map { it.asTemplateArg() } + tempArg[NEW_ALERTS_FIELD] = newAlerts.map { it.asTemplateArg() } + tempArg[COMPLETED_ALERTS_FIELD] = completedAlerts.map { it.asTemplateArg() } + tempArg[RESULTS_FIELD] = results return tempArg } + + companion object { + const val TRIGGER_FIELD = "trigger" + const val DEDUPED_ALERTS_FIELD = "dedupedAlerts" + const val NEW_ALERTS_FIELD = "newAlerts" + const val COMPLETED_ALERTS_FIELD = "completedAlerts" + const val RESULTS_FIELD = "results" + } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/script/DocumentLevelTriggerExecutionContext.kt b/alerting/src/main/kotlin/org/opensearch/alerting/script/DocumentLevelTriggerExecutionContext.kt index 66de731f6..8035d8f58 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/script/DocumentLevelTriggerExecutionContext.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/script/DocumentLevelTriggerExecutionContext.kt @@ -37,8 +37,13 @@ data class DocumentLevelTriggerExecutionContext( */ override fun asTemplateArg(): Map { val tempArg = super.asTemplateArg().toMutableMap() - tempArg["trigger"] = trigger.asTemplateArg() - tempArg["alerts"] = alerts.map { it.asTemplateArg() } + tempArg[TRIGGER_FIELD] = trigger.asTemplateArg() + tempArg[ALERTS_FIELD] = alerts.map { it.asTemplateArg() } return tempArg } + + companion object { + const val TRIGGER_FIELD = "trigger" + const val ALERTS_FIELD = "alerts" + } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/util/AggregationQueryRewriter.kt b/alerting/src/main/kotlin/org/opensearch/alerting/util/AggregationQueryRewriter.kt index ec62d0e84..5a211464d 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/util/AggregationQueryRewriter.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/util/AggregationQueryRewriter.kt @@ -18,6 +18,7 @@ import org.opensearch.search.aggregations.bucket.composite.CompositeAggregation import org.opensearch.search.aggregations.bucket.composite.CompositeAggregationBuilder import org.opensearch.search.aggregations.support.AggregationPath import org.opensearch.search.builder.SearchSourceBuilder +import org.opensearch.search.fetch.subphase.FetchSourceContext import org.opensearch.search.sort.SortOrder class AggregationQueryRewriter { @@ -62,6 +63,8 @@ class AggregationQueryRewriter { if (factory is CompositeAggregationBuilder) { if (returnSampleDocs) { // TODO: Returning sample documents should ideally be a toggleable option at the action level. + // For now, identify which fields to return from the doc _source for the trigger's actions. + val docFieldTags = parseSampleDocTags(listOf(trigger)) val sampleDocsAgg = listOf( AggregationBuilders.topHits("low_hits") .size(5) @@ -71,6 +74,7 @@ class AggregationQueryRewriter { .sort("_score", SortOrder.DESC) ) sampleDocsAgg.forEach { agg -> + if (docFieldTags.isNotEmpty()) agg.fetchSource(FetchSourceContext(true, docFieldTags.toTypedArray(), emptyArray())) if (!factory.subAggregations.contains(agg)) factory.subAggregation(agg) } } else { diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt b/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt index 0f092290c..9087631b7 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt @@ -8,12 +8,16 @@ package org.opensearch.alerting.util import org.apache.logging.log4j.LogManager import org.opensearch.alerting.model.BucketLevelTriggerRunResult import org.opensearch.alerting.model.destination.Destination +import org.opensearch.alerting.script.BucketLevelTriggerExecutionContext +import org.opensearch.alerting.script.DocumentLevelTriggerExecutionContext import org.opensearch.alerting.settings.DestinationSettings import org.opensearch.cluster.service.ClusterService import org.opensearch.common.settings.Settings import org.opensearch.common.util.concurrent.ThreadContext import org.opensearch.commons.alerting.model.AggregationResultBucket import org.opensearch.commons.alerting.model.AlertContext +import org.opensearch.commons.alerting.model.BucketLevelTrigger +import org.opensearch.commons.alerting.model.DocumentLevelTrigger import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.Trigger import org.opensearch.commons.alerting.model.action.Action @@ -183,30 +187,11 @@ fun ThreadContext.StoredContext.closeFinally(cause: Throwable?) = when (cause) { } } -/** - * Checks the `message_template.source` in the [Script] for each [Action] in the [Trigger] for - * any instances of [AlertContext.SAMPLE_DOCS_FIELD] tags. - * This indicates the message is expected to print data from the sample docs, so we need to collect the samples. - */ -fun printsSampleDocData(trigger: Trigger): Boolean { - return trigger.actions.any { action -> - // The {{ctx}} mustache tag indicates the entire ctx object should be printed in the message string. - // TODO: Consider excluding `{{ctx}}` from criteria for bucket-level triggers as printing all of - // their sample documents could make the notification message too large to send. - action.messageTemplate.idOrCode.contains("{{ctx}}") || - action.messageTemplate.idOrCode.contains(AlertContext.SAMPLE_DOCS_FIELD) - } -} - -fun printsSampleDocData(triggers: List): Boolean { - return triggers.any { trigger -> printsSampleDocData(trigger) } -} - /** * Mustache template supports iterating through a list using a `{{#listVariable}}{{/listVariable}}` block. * https://mustache.github.io/mustache.5.html * - * This function looks `{{#${[AlertContext.SAMPLE_DOCS_FIELD]}}}{{/${[AlertContext.SAMPLE_DOCS_FIELD]}}}` blocks, + * This function looks for `{{#${[AlertContext.SAMPLE_DOCS_FIELD]}}}{{/${[AlertContext.SAMPLE_DOCS_FIELD]}}}` blocks, * and parses the contents for tags, which we interpret as fields within the sample document. * * @return a [Set] of [String]s indicating fields within a document. @@ -214,30 +199,35 @@ fun printsSampleDocData(triggers: List): Boolean { fun parseSampleDocTags(messageTemplate: Script): Set { val sampleBlockPrefix = "{{#${AlertContext.SAMPLE_DOCS_FIELD}}}" val sampleBlockSuffix = "{{/${AlertContext.SAMPLE_DOCS_FIELD}}}" + val sourcePrefix = "_source." val tagRegex = Regex("\\{\\{([^{}]+)}}") val tags = mutableSetOf() try { // Identify the start and end points of the sample block - var sampleBlockStart = messageTemplate.idOrCode.indexOf(sampleBlockPrefix) - var sampleBlockEnd = messageTemplate.idOrCode.indexOf(sampleBlockSuffix, sampleBlockStart) + var blockStart = messageTemplate.idOrCode.indexOf(sampleBlockPrefix) + var blockEnd = messageTemplate.idOrCode.indexOf(sampleBlockSuffix, blockStart) // Sample start/end of -1 indicates there are no more complete sample blocks - while (sampleBlockStart != -1 && sampleBlockEnd != -1) { + while (blockStart != -1 && blockEnd != -1) { // Isolate the sample block - val sampleBlock = messageTemplate.idOrCode.substring(sampleBlockStart, sampleBlockEnd) + val sampleBlock = messageTemplate.idOrCode.substring(blockStart, blockEnd) // Remove the iteration wrapper tags - .removeSurrounding(sampleBlockPrefix, sampleBlockSuffix) + .removePrefix(sampleBlockPrefix) + .removeSuffix(sampleBlockSuffix) // Search for each tag tagRegex.findAll(sampleBlock).forEach { match -> - // Parse the field name from the tag (e.g., `{{_source.timestamp}}` becomes `_source.timestamp`) - val docField = match.groupValues[1].trim() - if (docField.isNotEmpty()) tags.add(docField) + // Parse the field name from the tag (e.g., `{{_source.timestamp}}` becomes `timestamp`) + var docField = match.groupValues[1].trim() + if (docField.startsWith(sourcePrefix)) { + docField = docField.removePrefix(sourcePrefix) + if (docField.isNotEmpty()) tags.add(docField) + } } // Identify any subsequent sample blocks - sampleBlockStart = messageTemplate.idOrCode.indexOf(sampleBlockPrefix, sampleBlockEnd) - sampleBlockEnd = messageTemplate.idOrCode.indexOf(sampleBlockSuffix, sampleBlockStart) + blockStart = messageTemplate.idOrCode.indexOf(sampleBlockPrefix, blockEnd) + blockEnd = messageTemplate.idOrCode.indexOf(sampleBlockSuffix, blockStart) } } catch (e: Exception) { logger.warn("Failed to parse sample document fields.", e) @@ -245,12 +235,36 @@ fun parseSampleDocTags(messageTemplate: Script): Set { return tags } -fun parseSampleDocTags(actions: List): Set { - return actions.flatMap { action -> parseSampleDocTags(action.messageTemplate) } - .toSet() +fun parseSampleDocTags(triggers: List): Set { + return triggers.flatMap { trigger -> + trigger.actions.flatMap { action -> parseSampleDocTags(action.messageTemplate) } + }.toSet() } -fun parseSampleDocTags(triggers: List): Set { - return triggers.flatMap { trigger -> parseSampleDocTags(trigger.actions) } - .toSet() +/** + * Checks the `message_template.source` in the [Script] for each [Action] in the [Trigger] for + * any instances of [AlertContext.SAMPLE_DOCS_FIELD] tags. + * This indicates the message is expected to print data from the sample docs, so we need to collect the samples. + */ +fun printsSampleDocData(trigger: Trigger): Boolean { + return trigger.actions.any { action -> + val alertsField = when (trigger) { + is BucketLevelTrigger -> "{{ctx.${BucketLevelTriggerExecutionContext.NEW_ALERTS_FIELD}}}" + is DocumentLevelTrigger -> "{{ctx.${DocumentLevelTriggerExecutionContext.ALERTS_FIELD}}}" + // Only bucket, and document level monitors are supported currently. + else -> return false + } + + // TODO: Consider excluding the following tags from TRUE criteria (especially for bucket-level triggers) as + // printing all of the sample documents could make the notification message too large to send. + // 1. {{ctx}} - prints entire ctx object in the message string + // 2. {{ctx.}} - prints entire alerts array in the message string, which includes the sample docs + // 3. {{AlertContext.SAMPLE_DOCS_FIELD}} - prints entire sample docs array in the message string + val validTags = listOfNotNull( + "{{ctx}}", + alertsField, + AlertContext.SAMPLE_DOCS_FIELD + ) + validTags.any { tag -> action.messageTemplate.idOrCode.contains(tag) } + } } diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt b/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt index 5067379cb..15715b040 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt @@ -2485,6 +2485,160 @@ class DocumentMonitorRunnerIT : AlertingRestTestCase() { } } + fun `test expected document and rules print in notification message`() { + val testTime = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now().truncatedTo(MILLIS)) + val testDoc = """{ + "message" : "Test message", + "test_strict_date_time" : "$testTime", + "test_field" : "us-west-2" + }""" + + val index = createTestIndex() + + val docQuery = DocLevelQuery(query = "\"us-west-2\"", fields = listOf(), name = "3") + val docLevelInput = DocLevelMonitorInput("description", listOf(index), listOf(docQuery)) + + // Prints all fields in doc source + val scriptSource1 = """ + Monitor {{ctx.monitor.name}} just entered alert status. Please investigate the issue.\n + - Trigger: {{ctx.trigger.name}}\n + - Severity: {{ctx.trigger.severity}}\n + - Period start: {{ctx.periodStart}}\n + - Period end: {{ctx.periodEnd}}\n\n + - New Alerts:\n + {{#ctx.alerts}}\n + Document values + {{#sample_documents}}\n + Test field: {{_source.test_field}}\n + Message: {{_source.message}}\n + Timestamp: {{_source.test_strict_date_time}}\n + {{/sample_documents}}\n + \n + Matching queries\n + {{#associated_queries}}\n + Query ID: {{id}}\n + Query name: {{name}}\n + {{/associated_queries}}\n + {{/ctx.alerts}} + """.trimIndent() + + // Only prints a few fields from the doc source + val scriptSource2 = """ + Monitor {{ctx.monitor.name}} just entered alert status. Please investigate the issue.\n + - Trigger: {{ctx.trigger.name}}\n + - Severity: {{ctx.trigger.severity}}\n + - Period start: {{ctx.periodStart}}\n + - Period end: {{ctx.periodEnd}}\n\n + - New Alerts:\n + {{#ctx.alerts}}\n + Document values + {{#sample_documents}}\n + Test field: {{_source.test_field}}\n + Message: {{_source.message}}\n + {{/sample_documents}}\n + \n + Matching queries\n + {{#associated_queries}}\n + Query ID: {{id}}\n + Query name: {{name}}\n + {{/associated_queries}}\n + {{/ctx.alerts}} + """.trimIndent() + + // Doesn't print any document data + val scriptSource3 = """ + Monitor {{ctx.monitor.name}} just entered alert status. Please investigate the issue.\n + - Trigger: {{ctx.trigger.name}}\n + - Severity: {{ctx.trigger.severity}}\n + - Period start: {{ctx.periodStart}}\n + - Period end: {{ctx.periodEnd}}\n\n + - New Alerts:\n + {{#ctx.alerts}}\n + Matching queries\n + {{#associated_queries}}\n + Query ID: {{id}}\n + Query name: {{name}}\n + {{/associated_queries}}\n + {{/ctx.alerts}} + """.trimIndent() + + // Using 'alert.copy()' here because 'randomAction()' applies the 'template' for the message subject, and message body + val actions = listOf( + randomAction(name = "action1", template = randomTemplateScript("action1 message"), destinationId = createDestination().id) + .copy(messageTemplate = randomTemplateScript(scriptSource1)), + randomAction(name = "action2", template = randomTemplateScript("action2 message"), destinationId = createDestination().id) + .copy(messageTemplate = randomTemplateScript(scriptSource2)), + randomAction(name = "action3", template = randomTemplateScript("action3 message"), destinationId = createDestination().id) + .copy(messageTemplate = randomTemplateScript(scriptSource3)) + ) + val monitor = createMonitor( + randomDocumentLevelMonitor( + inputs = listOf(docLevelInput), + triggers = listOf(randomDocumentLevelTrigger(condition = ALWAYS_RUN, actions = actions)) + ) + ) + + indexDoc(index, "", testDoc) + + val response = executeMonitor(monitor.id) + + val output = entityAsMap(response) + assertEquals(monitor.name, output["monitor_name"]) + + val triggerResults = output.objectMap("trigger_results") + assertEquals(1, triggerResults.values.size) + + val expectedMessageContents = mapOf( + "action1" to Pair( + // First item in pair is INCLUDED content + listOf( + "Test field: us-west-2", + "Message: Test message", + "Timestamp: $testTime", + "Query ID: ${docQuery.id}", + "Query name: ${docQuery.name}", + ), + // Second item in pair is EXCLUDED content + listOf() + ), + "action2" to Pair( + // First item in pair is INCLUDED content + listOf( + "Test field: us-west-2", + "Message: Test message", + "Query ID: ${docQuery.id}", + "Query name: ${docQuery.name}", + ), + // Second item in pair is EXCLUDED content + listOf("Timestamp: $testTime") + ), + "action3" to Pair( + // First item in pair is INCLUDED content + listOf( + "Query ID: ${docQuery.id}", + "Query name: ${docQuery.name}", + ), + // Second item in pair is EXCLUDED content + listOf( + "Test field: us-west-2", + "Message: Test message", + "Timestamp: $testTime", + ) + ), + ) + val actionResults = triggerResults.values.first().objectMap("action_results").values.first().values + @Suppress("UNCHECKED_CAST") + actionResults.forEach { action -> + val messageContent = ((action as Map)["output"] as Map)["message"] as String + expectedMessageContents[action["name"]]!!.first.forEach { + assertTrue(messageContent.contains(it)) + } + expectedMessageContents[action["name"]]!!.second.forEach { + assertFalse(messageContent.contains(it)) + } + } + } + @Suppress("UNCHECKED_CAST") /** helper that returns a field in a json map whose values are all json objects */ private fun Map.objectMap(key: String): Map> { diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/util/AlertingUtilsTests.kt b/alerting/src/test/kotlin/org/opensearch/alerting/util/AlertingUtilsTests.kt new file mode 100644 index 000000000..baad265af --- /dev/null +++ b/alerting/src/test/kotlin/org/opensearch/alerting/util/AlertingUtilsTests.kt @@ -0,0 +1,179 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting.util + +import org.opensearch.alerting.randomAction +import org.opensearch.alerting.randomBucketLevelTrigger +import org.opensearch.alerting.randomChainedAlertTrigger +import org.opensearch.alerting.randomDocumentLevelTrigger +import org.opensearch.alerting.randomQueryLevelTrigger +import org.opensearch.alerting.randomTemplateScript +import org.opensearch.alerting.script.BucketLevelTriggerExecutionContext +import org.opensearch.alerting.script.DocumentLevelTriggerExecutionContext +import org.opensearch.commons.alerting.model.AlertContext +import org.opensearch.test.OpenSearchTestCase + +class AlertingUtilsTests : OpenSearchTestCase() { + fun `test parseSampleDocTags only returns expected tags`() { + val expectedDocSourceTags = (0..3).map { "field$it" } + val unexpectedDocSourceTags = ((expectedDocSourceTags.size + 1)..(expectedDocSourceTags.size + 5)) + .map { "field$it" } + + val unexpectedTagsScriptSource = unexpectedDocSourceTags.joinToString { field -> "$field = {{$field}}" } + val expectedTagsScriptSource = unexpectedTagsScriptSource + """ + ${unexpectedDocSourceTags.joinToString("\n") { field -> "$field = {{$field}}" }} + {{#alerts}} + {{#${AlertContext.SAMPLE_DOCS_FIELD}}} + ${expectedDocSourceTags.joinToString("\n") { field -> "$field = {{_source.$field}}" }} + {{/${AlertContext.SAMPLE_DOCS_FIELD}}} + {{/alerts}} + """.trimIndent() + + // Action that prints doc source data + val trigger1 = randomDocumentLevelTrigger( + actions = listOf(randomAction(template = randomTemplateScript(source = expectedTagsScriptSource))) + ) + + // Action that does not print doc source data + val trigger2 = randomDocumentLevelTrigger( + actions = listOf(randomAction(template = randomTemplateScript(source = unexpectedTagsScriptSource))) + ) + + // No actions + val trigger3 = randomDocumentLevelTrigger(actions = listOf()) + + val tags = parseSampleDocTags(listOf(trigger1, trigger2, trigger3)) + + assertEquals(expectedDocSourceTags.size, tags.size) + expectedDocSourceTags.forEach { tag -> assertTrue(tags.contains(tag)) } + unexpectedDocSourceTags.forEach { tag -> assertFalse(tags.contains(tag)) } + } + + fun `test printsSampleDocData entire ctx tag returns TRUE`() { + val tag = "{{ctx}}" + val triggers = listOf( + randomBucketLevelTrigger(actions = listOf(randomAction(template = randomTemplateScript(source = tag)))), + randomDocumentLevelTrigger(actions = listOf(randomAction(template = randomTemplateScript(source = tag)))) + ) + + triggers.forEach { trigger -> assertTrue(printsSampleDocData(trigger)) } + } + + fun `test printsSampleDocData entire alerts tag returns TRUE`() { + val triggers = listOf( + randomBucketLevelTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = "{{ctx.${BucketLevelTriggerExecutionContext.NEW_ALERTS_FIELD}}}" + ) + ) + ) + ), + randomDocumentLevelTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = "{{ctx.${DocumentLevelTriggerExecutionContext.ALERTS_FIELD}}}" + ) + ) + ) + ) + ) + + triggers.forEach { trigger -> assertTrue(printsSampleDocData(trigger)) } + } + + fun `test printsSampleDocData entire sample_docs tag returns TRUE`() { + val triggers = listOf( + randomBucketLevelTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = """ + {{#ctx.${BucketLevelTriggerExecutionContext.NEW_ALERTS_FIELD}}} + {{${AlertContext.SAMPLE_DOCS_FIELD}}} + {{/ctx.${BucketLevelTriggerExecutionContext.NEW_ALERTS_FIELD}}} + """.trimIndent() + ) + ) + ) + ), + randomDocumentLevelTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = """ + {{#ctx.${DocumentLevelTriggerExecutionContext.ALERTS_FIELD}}} + {{${AlertContext.SAMPLE_DOCS_FIELD}}} + {{/ctx.${DocumentLevelTriggerExecutionContext.ALERTS_FIELD}}} + """.trimIndent() + ) + ) + ) + ) + ) + + triggers.forEach { trigger -> assertTrue(printsSampleDocData(trigger)) } + } + + fun `test printsSampleDocData sample_docs iteration block returns TRUE`() { + val triggers = listOf( + randomBucketLevelTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = """ + {{#ctx.${BucketLevelTriggerExecutionContext.NEW_ALERTS_FIELD}}} + "{{#${AlertContext.SAMPLE_DOCS_FIELD}}}" + {{_source.field}} + "{{/${AlertContext.SAMPLE_DOCS_FIELD}}}" + {{/ctx.${BucketLevelTriggerExecutionContext.NEW_ALERTS_FIELD}}} + """.trimIndent() + ) + ) + ) + ), + randomDocumentLevelTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = """ + {{#ctx.${DocumentLevelTriggerExecutionContext.ALERTS_FIELD}}} + {{#${AlertContext.SAMPLE_DOCS_FIELD}}} + {{_source.field}} + {{/${AlertContext.SAMPLE_DOCS_FIELD}}} + {{/ctx.${DocumentLevelTriggerExecutionContext.ALERTS_FIELD}}} + """.trimIndent() + ) + ) + ) + ) + ) + + triggers.forEach { trigger -> assertTrue(printsSampleDocData(trigger)) } + } + + fun `test printsSampleDocData unrelated tag returns FALSE`() { + val tag = "{{ctx.monitor.name}}" + val triggers = listOf( + randomBucketLevelTrigger(actions = listOf(randomAction(template = randomTemplateScript(source = tag)))), + randomDocumentLevelTrigger(actions = listOf(randomAction(template = randomTemplateScript(source = tag)))) + ) + + triggers.forEach { trigger -> assertFalse(printsSampleDocData(trigger)) } + } + + fun `test printsSampleDocData unsupported trigger types return FALSE`() { + val tag = "{{ctx}}" + val triggers = listOf( + randomQueryLevelTrigger(actions = listOf(randomAction(template = randomTemplateScript(source = tag)))), + randomChainedAlertTrigger(actions = listOf(randomAction(template = randomTemplateScript(source = tag)))) + ) + + triggers.forEach { trigger -> assertFalse(printsSampleDocData(trigger)) } + } +}