Skip to content

Commit e63fd53

Browse files
authored
Watcher: Allow to execute actions for each element in array (#41997)
This adds the ability to execute an action for each element that occurs in an array, for example you could sent a dedicated slack action for each search hit returned from a search. There is also a limit for the number of actions executed, which is hardcoded to 100 right now, to prevent having watches run forever. The watch history logs each action result and the total number of actions the were executed. Relates #34546
1 parent d6f36a8 commit e63fd53

File tree

12 files changed

+447
-31
lines changed

12 files changed

+447
-31
lines changed

x-pack/docs/en/watcher/actions.asciidoc

+43
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,49 @@ of a watch during its execution:
192192
image::images/action-throttling.jpg[align="center"]
193193

194194

195+
[[action-foreach]]
196+
=== Running an action for each element in an array
197+
198+
You can use the `foreach` field in an action to trigger the configured action
199+
for every element within that array.
200+
201+
In order to protect from long running watches, after one hundred runs with an
202+
foreach loop the execution is gracefully stopped.
203+
204+
[source,js]
205+
--------------------------------------------------
206+
PUT _watcher/watch/log_event_watch
207+
{
208+
"trigger" : {
209+
"schedule" : { "interval" : "5m" }
210+
},
211+
"input" : {
212+
"search" : {
213+
"request" : {
214+
"indices" : "log-events",
215+
"body" : {
216+
"query" : { "match" : { "status" : "error" } }
217+
}
218+
}
219+
}
220+
},
221+
"condition" : {
222+
"compare" : { "ctx.payload.hits.total" : { "gt" : 0 } }
223+
},
224+
"actions" : {
225+
"log_hits" : {
226+
"foreach" : "ctx.payload.hits.hits", <1>
227+
"logging" : {
228+
"text" : "Found id {{ctx.payload._id}} with field {{ctx.payload._source.my_field}}"
229+
}
230+
}
231+
}
232+
}
233+
--------------------------------------------------
234+
// CONSOLE
235+
236+
<1> The logging statement will be executed for each of the returned search hits.
237+
195238
[[action-conditions]]
196239
=== Adding conditions to actions
197240

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/actions/ActionWrapper.java

+105-8
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77

88
import org.apache.logging.log4j.message.ParameterizedMessage;
99
import org.apache.logging.log4j.util.Supplier;
10+
import org.elasticsearch.ElasticsearchException;
1011
import org.elasticsearch.ElasticsearchParseException;
1112
import org.elasticsearch.common.Nullable;
13+
import org.elasticsearch.common.Strings;
1214
import org.elasticsearch.common.unit.TimeValue;
15+
import org.elasticsearch.common.xcontent.ObjectPath;
1316
import org.elasticsearch.common.xcontent.ToXContentObject;
1417
import org.elasticsearch.common.xcontent.XContentBuilder;
1518
import org.elasticsearch.common.xcontent.XContentParser;
1619
import org.elasticsearch.license.XPackLicenseState;
20+
import org.elasticsearch.script.JodaCompatibleZonedDateTime;
1721
import org.elasticsearch.xpack.core.watcher.actions.throttler.ActionThrottler;
1822
import org.elasticsearch.xpack.core.watcher.actions.throttler.Throttler;
1923
import org.elasticsearch.xpack.core.watcher.actions.throttler.ThrottlerField;
@@ -30,29 +34,42 @@
3034
import java.time.Clock;
3135
import java.time.ZoneOffset;
3236
import java.time.ZonedDateTime;
37+
import java.util.ArrayList;
38+
import java.util.Collection;
39+
import java.util.HashMap;
40+
import java.util.List;
41+
import java.util.Map;
3342
import java.util.Objects;
43+
import java.util.Set;
44+
import java.util.stream.Collectors;
3445

3546
import static org.elasticsearch.common.unit.TimeValue.timeValueMillis;
3647

3748
public class ActionWrapper implements ToXContentObject {
3849

50+
private final int MAXIMUM_FOREACH_RUNS = 100;
51+
3952
private String id;
4053
@Nullable
4154
private final ExecutableCondition condition;
4255
@Nullable
4356
private final ExecutableTransform<Transform, Transform.Result> transform;
4457
private final ActionThrottler throttler;
4558
private final ExecutableAction<? extends Action> action;
59+
@Nullable
60+
private String path;
4661

4762
public ActionWrapper(String id, ActionThrottler throttler,
4863
@Nullable ExecutableCondition condition,
4964
@Nullable ExecutableTransform<Transform, Transform.Result> transform,
50-
ExecutableAction<? extends Action> action) {
65+
ExecutableAction<? extends Action> action,
66+
@Nullable String path) {
5167
this.id = id;
5268
this.condition = condition;
5369
this.throttler = throttler;
5470
this.transform = transform;
5571
this.action = action;
72+
this.path = path;
5673
}
5774

5875
public String id() {
@@ -140,16 +157,90 @@ public ActionWrapperResult execute(WatchExecutionContext ctx) {
140157
return new ActionWrapperResult(id, conditionResult, null, new Action.Result.FailureWithException(action.type(), e));
141158
}
142159
}
143-
try {
144-
Action.Result actionResult = action.execute(id, ctx, payload);
145-
return new ActionWrapperResult(id, conditionResult, transformResult, actionResult);
146-
} catch (Exception e) {
147-
action.logger().error(
160+
if (Strings.isEmpty(path)) {
161+
try {
162+
Action.Result actionResult = action.execute(id, ctx, payload);
163+
return new ActionWrapperResult(id, conditionResult, transformResult, actionResult);
164+
} catch (Exception e) {
165+
action.logger().error(
166+
(Supplier<?>) () -> new ParameterizedMessage("failed to execute action [{}/{}]", ctx.watch().id(), id), e);
167+
return new ActionWrapperResult(id, new Action.Result.FailureWithException(action.type(), e));
168+
}
169+
} else {
170+
try {
171+
List<Action.Result> results = new ArrayList<>();
172+
Object object = ObjectPath.eval(path, toMap(ctx));
173+
int runs = 0;
174+
if (object instanceof Collection) {
175+
Collection collection = Collection.class.cast(object);
176+
if (collection.isEmpty()) {
177+
throw new ElasticsearchException("foreach object [{}] was an empty list, could not run any action", path);
178+
} else {
179+
for (Object o : collection) {
180+
if (runs >= MAXIMUM_FOREACH_RUNS) {
181+
break;
182+
}
183+
if (o instanceof Map) {
184+
results.add(action.execute(id, ctx, new Payload.Simple((Map<String, Object>) o)));
185+
} else {
186+
results.add(action.execute(id, ctx, new Payload.Simple("_value", o)));
187+
}
188+
runs++;
189+
}
190+
}
191+
} else if (object == null) {
192+
throw new ElasticsearchException("specified foreach object was null: [{}]", path);
193+
} else {
194+
throw new ElasticsearchException("specified foreach object was not a an array/collection: [{}]", path);
195+
}
196+
197+
// check if we have mixed results, then set to partial failure
198+
final Set<Action.Result.Status> statuses = results.stream().map(Action.Result::status).collect(Collectors.toSet());
199+
Action.Result.Status status;
200+
if (statuses.size() == 1) {
201+
status = statuses.iterator().next();
202+
} else {
203+
status = Action.Result.Status.PARTIAL_FAILURE;
204+
}
205+
206+
final int numberOfActionsExecuted = runs;
207+
return new ActionWrapperResult(id, conditionResult, transformResult,
208+
new Action.Result(action.type(), status) {
209+
@Override
210+
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
211+
builder.field("number_of_actions_executed", numberOfActionsExecuted);
212+
builder.startArray(WatchField.FOREACH.getPreferredName());
213+
for (Action.Result result : results) {
214+
builder.startObject();
215+
result.toXContent(builder, params);
216+
builder.endObject();
217+
}
218+
builder.endArray();
219+
return builder;
220+
}
221+
});
222+
} catch (Exception e) {
223+
action.logger().error(
148224
(Supplier<?>) () -> new ParameterizedMessage("failed to execute action [{}/{}]", ctx.watch().id(), id), e);
149-
return new ActionWrapperResult(id, new Action.Result.FailureWithException(action.type(), e));
225+
return new ActionWrapperResult(id, new Action.Result.FailureWithException(action.type(), e));
226+
}
150227
}
151228
}
152229

230+
private Map<String, Object> toMap(WatchExecutionContext ctx) {
231+
Map<String, Object> model = new HashMap<>();
232+
model.put("id", ctx.id().value());
233+
model.put("watch_id", ctx.id().watchId());
234+
model.put("execution_time", new JodaCompatibleZonedDateTime(ctx.executionTime().toInstant(), ZoneOffset.UTC));
235+
model.put("trigger", ctx.triggerEvent().data());
236+
model.put("metadata", ctx.watch().metadata());
237+
model.put("vars", ctx.vars());
238+
if (ctx.payload().data() != null) {
239+
model.put("payload", ctx.payload().data());
240+
}
241+
return Map.of("ctx", model);
242+
}
243+
153244
@Override
154245
public boolean equals(Object o) {
155246
if (this == o) return true;
@@ -186,6 +277,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
186277
.field(transform.type(), transform, params)
187278
.endObject();
188279
}
280+
if (Strings.isEmpty(path) == false) {
281+
builder.field(WatchField.FOREACH.getPreferredName(), path);
282+
}
189283
builder.field(action.type(), action, params);
190284
return builder.endObject();
191285
}
@@ -198,6 +292,7 @@ static ActionWrapper parse(String watchId, String actionId, XContentParser parse
198292
ExecutableCondition condition = null;
199293
ExecutableTransform<Transform, Transform.Result> transform = null;
200294
TimeValue throttlePeriod = null;
295+
String path = null;
201296
ExecutableAction<? extends Action> action = null;
202297

203298
String currentFieldName = null;
@@ -208,6 +303,8 @@ static ActionWrapper parse(String watchId, String actionId, XContentParser parse
208303
} else {
209304
if (WatchField.CONDITION.match(currentFieldName, parser.getDeprecationHandler())) {
210305
condition = actionRegistry.getConditionRegistry().parseExecutable(watchId, parser);
306+
} else if (WatchField.FOREACH.match(currentFieldName, parser.getDeprecationHandler())) {
307+
path = parser.text();
211308
} else if (Transform.TRANSFORM.match(currentFieldName, parser.getDeprecationHandler())) {
212309
transform = actionRegistry.getTransformRegistry().parse(watchId, parser);
213310
} else if (ThrottlerField.THROTTLE_PERIOD.match(currentFieldName, parser.getDeprecationHandler())) {
@@ -235,7 +332,7 @@ static ActionWrapper parse(String watchId, String actionId, XContentParser parse
235332
}
236333

237334
ActionThrottler throttler = new ActionThrottler(clock, throttlePeriod, licenseState);
238-
return new ActionWrapper(actionId, throttler, condition, transform, action);
335+
return new ActionWrapper(actionId, throttler, condition, transform, action, path);
239336
}
240337

241338
}

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/client/WatchSourceBuilder.java

+14-3
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public WatchSourceBuilder addAction(String id, TimeValue throttlePeriod, Transfo
101101
}
102102

103103
public WatchSourceBuilder addAction(String id, TimeValue throttlePeriod, Transform transform, Action action) {
104-
actions.put(id, new TransformedAction(id, action, throttlePeriod, null, transform));
104+
actions.put(id, new TransformedAction(id, action, throttlePeriod, null, transform, null));
105105
return this;
106106
}
107107

@@ -111,7 +111,13 @@ public WatchSourceBuilder addAction(String id, TimeValue throttlePeriod, Conditi
111111
}
112112

113113
public WatchSourceBuilder addAction(String id, TimeValue throttlePeriod, Condition condition, Transform transform, Action action) {
114-
actions.put(id, new TransformedAction(id, action, throttlePeriod, condition, transform));
114+
actions.put(id, new TransformedAction(id, action, throttlePeriod, condition, transform, null));
115+
return this;
116+
}
117+
118+
public WatchSourceBuilder addAction(String id, TimeValue throttlePeriod, Condition condition, Transform transform, String path,
119+
Action action) {
120+
actions.put(id, new TransformedAction(id, action, throttlePeriod, condition, transform, path));
115121
return this;
116122
}
117123

@@ -186,16 +192,18 @@ public final BytesReference buildAsBytes(XContentType contentType) {
186192
static class TransformedAction implements ToXContentObject {
187193

188194
private final Action action;
195+
@Nullable private String path;
189196
@Nullable private final TimeValue throttlePeriod;
190197
@Nullable private final Condition condition;
191198
@Nullable private final Transform transform;
192199

193200
TransformedAction(String id, Action action, @Nullable TimeValue throttlePeriod,
194-
@Nullable Condition condition, @Nullable Transform transform) {
201+
@Nullable Condition condition, @Nullable Transform transform, @Nullable String path) {
195202
this.throttlePeriod = throttlePeriod;
196203
this.condition = condition;
197204
this.transform = transform;
198205
this.action = action;
206+
this.path = path;
199207
}
200208

201209
@Override
@@ -215,6 +223,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
215223
.field(transform.type(), transform, params)
216224
.endObject();
217225
}
226+
if (path != null) {
227+
builder.field("foreach", path);
228+
}
218229
builder.field(action.type(), action, params);
219230
return builder.endObject();
220231
}

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/support/WatcherIndexTemplateRegistryField.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ public final class WatcherIndexTemplateRegistryField {
1414
// version 7: add full exception stack traces for better debugging
1515
// version 8: fix slack attachment property not to be dynamic, causing field type issues
1616
// version 9: add a user field defining which user executed the watch
17+
// version 10: add support for foreach path in actions
1718
// Note: if you change this, also inform the kibana team around the watcher-ui
18-
public static final String INDEX_TEMPLATE_VERSION = "9";
19+
public static final String INDEX_TEMPLATE_VERSION = "10";
1920
public static final String HISTORY_TEMPLATE_NAME = ".watch-history-" + INDEX_TEMPLATE_VERSION;
2021
public static final String HISTORY_TEMPLATE_NAME_NO_ILM = ".watch-history-no-ilm-" + INDEX_TEMPLATE_VERSION;
2122
public static final String TRIGGERED_TEMPLATE_NAME = ".triggered_watches";

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/watch/WatchField.java

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public final class WatchField {
1313
public static final ParseField CONDITION = new ParseField("condition");
1414
public static final ParseField ACTIONS = new ParseField("actions");
1515
public static final ParseField TRANSFORM = new ParseField("transform");
16+
public static final ParseField FOREACH = new ParseField("foreach");
1617
public static final ParseField THROTTLE_PERIOD = new ParseField("throttle_period_in_millis");
1718
public static final ParseField THROTTLE_PERIOD_HUMAN = new ParseField("throttle_period");
1819
public static final ParseField METADATA = new ParseField("metadata");

x-pack/plugin/core/src/main/resources/watch-history.json

+7
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,13 @@
264264
"reason" : {
265265
"type" : "keyword"
266266
},
267+
"number_of_actions_executed": {
268+
"type": "integer"
269+
},
270+
"foreach" : {
271+
"type": "object",
272+
"enabled" : false
273+
},
267274
"email": {
268275
"type": "object",
269276
"dynamic": true,

0 commit comments

Comments
 (0)