Skip to content

Commit b796632

Browse files
authored
[ML] Allow datafeed and job configs for datafeed preview API (#70836)
Previously, a datafeed and job must already exist for the `_preview` API to work. With this change, users can get an accurate preview of the data that will be sent to the anomaly detection job without creating either of them. closes #70264
1 parent 77cd0b5 commit b796632

File tree

13 files changed

+591
-58
lines changed

13 files changed

+591
-58
lines changed

client/rest-high-level/src/main/java/org/elasticsearch/client/MLRequestConverters.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -326,14 +326,18 @@ static Request getDatafeedStats(GetDatafeedStatsRequest getDatafeedStatsRequest)
326326
return request;
327327
}
328328

329-
static Request previewDatafeed(PreviewDatafeedRequest previewDatafeedRequest) {
330-
String endpoint = new EndpointBuilder()
329+
static Request previewDatafeed(PreviewDatafeedRequest previewDatafeedRequest) throws IOException {
330+
EndpointBuilder builder = new EndpointBuilder()
331331
.addPathPartAsIs("_ml")
332-
.addPathPartAsIs("datafeeds")
333-
.addPathPart(previewDatafeedRequest.getDatafeedId())
334-
.addPathPartAsIs("_preview")
335-
.build();
336-
return new Request(HttpGet.METHOD_NAME, endpoint);
332+
.addPathPartAsIs("datafeeds");
333+
String endpoint = previewDatafeedRequest.getDatafeedId() != null ?
334+
builder.addPathPart(previewDatafeedRequest.getDatafeedId()).addPathPartAsIs("_preview").build() :
335+
builder.addPathPartAsIs("_preview").build();
336+
Request request = new Request(HttpPost.METHOD_NAME, endpoint);
337+
if (previewDatafeedRequest.getDatafeedId() == null) {
338+
request.setEntity(createEntity(previewDatafeedRequest, REQUEST_BODY_CONTENT_TYPE));
339+
}
340+
return request;
337341
}
338342

339343
static Request deleteForecast(DeleteForecastRequest deleteForecastRequest) {

client/rest-high-level/src/main/java/org/elasticsearch/client/ml/PreviewDatafeedRequest.java

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
import org.elasticsearch.client.Validatable;
1111
import org.elasticsearch.client.ml.datafeed.DatafeedConfig;
12+
import org.elasticsearch.client.ml.job.config.Job;
13+
import org.elasticsearch.common.Nullable;
14+
import org.elasticsearch.common.ParseField;
1215
import org.elasticsearch.common.Strings;
1316
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
1417
import org.elasticsearch.common.xcontent.ToXContentObject;
@@ -23,18 +26,34 @@
2326
*/
2427
public class PreviewDatafeedRequest implements Validatable, ToXContentObject {
2528

29+
private static final ParseField DATAFEED_CONFIG = new ParseField("datafeed_config");
30+
private static final ParseField JOB_CONFIG = new ParseField("job_config");
31+
2632
public static final ConstructingObjectParser<PreviewDatafeedRequest, Void> PARSER = new ConstructingObjectParser<>(
27-
"open_datafeed_request", true, a -> new PreviewDatafeedRequest((String) a[0]));
33+
"preview_datafeed_request",
34+
a -> new PreviewDatafeedRequest((String) a[0], (DatafeedConfig.Builder) a[1], (Job.Builder) a[2]));
2835

2936
static {
30-
PARSER.declareString(ConstructingObjectParser.constructorArg(), DatafeedConfig.ID);
37+
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), DatafeedConfig.ID);
38+
PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), DatafeedConfig.PARSER, DATAFEED_CONFIG);
39+
PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), Job.PARSER, JOB_CONFIG);
3140
}
3241

3342
public static PreviewDatafeedRequest fromXContent(XContentParser parser) throws IOException {
3443
return PARSER.parse(parser, null);
3544
}
3645

3746
private final String datafeedId;
47+
private final DatafeedConfig datafeedConfig;
48+
private final Job jobConfig;
49+
50+
private PreviewDatafeedRequest(@Nullable String datafeedId,
51+
@Nullable DatafeedConfig.Builder datafeedConfig,
52+
@Nullable Job.Builder jobConfig) {
53+
this.datafeedId = datafeedId;
54+
this.datafeedConfig = datafeedConfig == null ? null : datafeedConfig.build();
55+
this.jobConfig = jobConfig == null ? null : jobConfig.build();
56+
}
3857

3958
/**
4059
* Create a new request with the desired datafeedId
@@ -43,16 +62,45 @@ public static PreviewDatafeedRequest fromXContent(XContentParser parser) throws
4362
*/
4463
public PreviewDatafeedRequest(String datafeedId) {
4564
this.datafeedId = Objects.requireNonNull(datafeedId, "[datafeed_id] must not be null");
65+
this.datafeedConfig = null;
66+
this.jobConfig = null;
67+
}
68+
69+
/**
70+
* Create a new request to preview the provided datafeed config and optional job config
71+
* @param datafeedConfig The datafeed to preview
72+
* @param jobConfig The associated job config (required if the datafeed does not refer to an existing job)
73+
*/
74+
public PreviewDatafeedRequest(DatafeedConfig datafeedConfig, Job jobConfig) {
75+
this.datafeedId = null;
76+
this.datafeedConfig = datafeedConfig;
77+
this.jobConfig = jobConfig;
4678
}
4779

4880
public String getDatafeedId() {
4981
return datafeedId;
5082
}
5183

84+
public DatafeedConfig getDatafeedConfig() {
85+
return datafeedConfig;
86+
}
87+
88+
public Job getJobConfig() {
89+
return jobConfig;
90+
}
91+
5292
@Override
5393
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
5494
builder.startObject();
55-
builder.field(DatafeedConfig.ID.getPreferredName(), datafeedId);
95+
if (datafeedId != null) {
96+
builder.field(DatafeedConfig.ID.getPreferredName(), datafeedId);
97+
}
98+
if (datafeedConfig != null) {
99+
builder.field(DATAFEED_CONFIG.getPreferredName(), datafeedConfig);
100+
}
101+
if (jobConfig != null) {
102+
builder.field(JOB_CONFIG.getPreferredName(), jobConfig);
103+
}
56104
builder.endObject();
57105
return builder;
58106
}
@@ -64,7 +112,7 @@ public String toString() {
64112

65113
@Override
66114
public int hashCode() {
67-
return Objects.hash(datafeedId);
115+
return Objects.hash(datafeedId, datafeedConfig, jobConfig);
68116
}
69117

70118
@Override
@@ -78,6 +126,8 @@ public boolean equals(Object other) {
78126
}
79127

80128
PreviewDatafeedRequest that = (PreviewDatafeedRequest) other;
81-
return Objects.equals(datafeedId, that.datafeedId);
129+
return Objects.equals(datafeedId, that.datafeedId)
130+
&& Objects.equals(datafeedConfig, that.datafeedConfig)
131+
&& Objects.equals(jobConfig, that.jobConfig);
82132
}
83133
}

client/rest-high-level/src/test/java/org/elasticsearch/client/MLRequestConvertersTests.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
import org.elasticsearch.client.ml.job.config.AnalysisConfig;
9292
import org.elasticsearch.client.ml.job.config.Detector;
9393
import org.elasticsearch.client.ml.job.config.Job;
94+
import org.elasticsearch.client.ml.job.config.JobTests;
9495
import org.elasticsearch.client.ml.job.config.JobUpdate;
9596
import org.elasticsearch.client.ml.job.config.JobUpdateTests;
9697
import org.elasticsearch.client.ml.job.config.MlFilter;
@@ -390,11 +391,24 @@ public void testGetDatafeedStats() {
390391
assertEquals(Boolean.toString(true), request.getParameters().get("allow_no_match"));
391392
}
392393

393-
public void testPreviewDatafeed() {
394+
public void testPreviewDatafeed() throws IOException {
394395
PreviewDatafeedRequest datafeedRequest = new PreviewDatafeedRequest("datafeed_1");
395396
Request request = MLRequestConverters.previewDatafeed(datafeedRequest);
396-
assertEquals(HttpGet.METHOD_NAME, request.getMethod());
397+
assertEquals(HttpPost.METHOD_NAME, request.getMethod());
397398
assertEquals("/_ml/datafeeds/" + datafeedRequest.getDatafeedId() + "/_preview", request.getEndpoint());
399+
assertThat(request.getEntity(), is(nullValue()));
400+
401+
datafeedRequest = new PreviewDatafeedRequest(
402+
DatafeedConfigTests.createRandom(),
403+
randomBoolean() ? null : JobTests.createRandomizedJob()
404+
);
405+
request = MLRequestConverters.previewDatafeed(datafeedRequest);
406+
assertEquals(HttpPost.METHOD_NAME, request.getMethod());
407+
assertEquals("/_ml/datafeeds/_preview", request.getEndpoint());
408+
try (XContentParser parser = createParser(JsonXContent.jsonXContent, request.getEntity().getContent())) {
409+
PreviewDatafeedRequest parsedDatafeedRequest = PreviewDatafeedRequest.PARSER.apply(parser, null);
410+
assertThat(parsedDatafeedRequest, equalTo(datafeedRequest));
411+
}
398412
}
399413

400414
public void testDeleteForecast() {

client/rest-high-level/src/test/java/org/elasticsearch/client/ml/PreviewDatafeedRequestTests.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package org.elasticsearch.client.ml;
99

1010
import org.elasticsearch.client.ml.datafeed.DatafeedConfigTests;
11+
import org.elasticsearch.client.ml.job.config.JobTests;
1112
import org.elasticsearch.common.xcontent.XContentParser;
1213
import org.elasticsearch.test.AbstractXContentTestCase;
1314

@@ -17,7 +18,9 @@ public class PreviewDatafeedRequestTests extends AbstractXContentTestCase<Previe
1718

1819
@Override
1920
protected PreviewDatafeedRequest createTestInstance() {
20-
return new PreviewDatafeedRequest(DatafeedConfigTests.randomValidDatafeedId());
21+
return randomBoolean() ?
22+
new PreviewDatafeedRequest(DatafeedConfigTests.randomValidDatafeedId()) :
23+
new PreviewDatafeedRequest(DatafeedConfigTests.createRandom(), randomBoolean() ? null : JobTests.createRandomizedJob());
2124
}
2225

2326
@Override
@@ -27,6 +30,6 @@ protected PreviewDatafeedRequest doParseInstance(XContentParser parser) throws I
2730

2831
@Override
2932
protected boolean supportsUnknownFields() {
30-
return true;
33+
return false;
3134
}
3235
}

docs/reference/ml/anomaly-detection/apis/preview-datafeed.asciidoc

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ Previews a {dfeed}.
1313
[[ml-preview-datafeed-request]]
1414
== {api-request-title}
1515

16-
`GET _ml/datafeeds/<datafeed_id>/_preview`
16+
`GET _ml/datafeeds/<datafeed_id>/_preview` +
17+
18+
`POST _ml/datafeeds/<datafeed_id>/_preview` +
19+
20+
`GET _ml/datafeeds/_preview` +
21+
22+
`POST _ml/datafeeds/_preview`
1723

1824
[[ml-preview-datafeed-prereqs]]
1925
== {api-prereq-title}
@@ -25,9 +31,10 @@ Previews a {dfeed}.
2531
[[ml-preview-datafeed-desc]]
2632
== {api-description-title}
2733

28-
The preview {dfeeds} API returns the first "page" of results from the `search`
29-
that is created by using the current {dfeed} settings. This preview shows the
30-
structure of the data that will be passed to the anomaly detection engine.
34+
The preview {dfeeds} API returns the first "page" of search results from a
35+
{dfeed}. You can preview an existing {dfeed} or provide configuration details
36+
for the {dfeed} and {anomaly-job} in the API. The preview shows the structure of
37+
the data that will be passed to the anomaly detection engine.
3138

3239
IMPORTANT: When {es} {security-features} are enabled, the {dfeed} query is
3340
previewed using the credentials of the user calling the preview {dfeed} API.
@@ -43,12 +50,32 @@ supply the credentials.
4350
== {api-path-parms-title}
4451

4552
`<datafeed_id>`::
46-
(Required, string)
53+
(Optional, string)
4754
include::{es-repo-dir}/ml/ml-shared.asciidoc[tag=datafeed-id]
55+
+
56+
NOTE: If you provide the `<datafeed_id>` as a path parameter, you cannot
57+
provide {dfeed} or {anomaly-job} configuration details in the request body.
58+
59+
[[ml-preview-datafeed-request-body]]
60+
== {api-request-body-title}
61+
62+
`datafeed_config`::
63+
(Optional, object) The {dfeed} definition to preview. For valid definitions, see
64+
the <<ml-put-datafeed-request-body,create {dfeeds} API>>.
65+
66+
`job_config`::
67+
(Optional, object) The configuration details for the {anomaly-job} that is
68+
associated with the {dfeed}. If the `datafeed_config` object does not include a
69+
`job_id` that references an existing {anomaly-job}, you must supply this
70+
`job_config` object. If you include both a `job_id` and a `job_config`, the
71+
latter information is used. You cannot specify a `job_config` object unless you also supply a `datafeed_config` object. For valid definitions, see the
72+
<<ml-put-job-request-body,create {anomaly-jobs} API>>.
4873

4974
[[ml-preview-datafeed-example]]
5075
== {api-examples-title}
5176

77+
This is an example of providing the ID of an existing {dfeed}:
78+
5279
[source,console]
5380
--------------------------------------------------
5481
GET _ml/datafeeds/datafeed-high_sum_total_sales/_preview
@@ -86,3 +113,88 @@ The data that is returned for this example is as follows:
86113
}
87114
]
88115
----
116+
117+
The following example provides {dfeed} and {anomaly-job} configuration
118+
details in the API:
119+
120+
[source,console]
121+
--------------------------------------------------
122+
POST _ml/datafeeds/_preview
123+
{
124+
"datafeed_config": {
125+
"indices" : [
126+
"kibana_sample_data_ecommerce"
127+
],
128+
"query" : {
129+
"bool" : {
130+
"filter" : [
131+
{
132+
"term" : {
133+
"_index" : "kibana_sample_data_ecommerce"
134+
}
135+
}
136+
]
137+
}
138+
},
139+
"scroll_size" : 1000
140+
},
141+
"job_config": {
142+
"description" : "Find customers spending an unusually high amount in an hour",
143+
"analysis_config" : {
144+
"bucket_span" : "1h",
145+
"detectors" : [
146+
{
147+
"detector_description" : "High total sales",
148+
"function" : "high_sum",
149+
"field_name" : "taxful_total_price",
150+
"over_field_name" : "customer_full_name.keyword"
151+
}
152+
],
153+
"influencers" : [
154+
"customer_full_name.keyword",
155+
"category.keyword"
156+
]
157+
},
158+
"analysis_limits" : {
159+
"model_memory_limit" : "10mb"
160+
},
161+
"data_description" : {
162+
"time_field" : "order_date",
163+
"time_format" : "epoch_ms"
164+
}
165+
}
166+
}
167+
--------------------------------------------------
168+
// TEST[skip:set up Kibana sample data]
169+
170+
The data that is returned for this example is as follows:
171+
172+
[source,console-result]
173+
----
174+
[
175+
{
176+
"order_date" : 1574294659000,
177+
"category.keyword" : "Men's Clothing",
178+
"customer_full_name.keyword" : "Sultan Al Benson",
179+
"taxful_total_price" : 35.96875
180+
},
181+
{
182+
"order_date" : 1574294918000,
183+
"category.keyword" : [
184+
"Women's Accessories",
185+
"Women's Clothing"
186+
],
187+
"customer_full_name.keyword" : "Pia Webb",
188+
"taxful_total_price" : 83.0
189+
},
190+
{
191+
"order_date" : 1574295782000,
192+
"category.keyword" : [
193+
"Women's Accessories",
194+
"Women's Shoes"
195+
],
196+
"customer_full_name.keyword" : "Brigitte Graham",
197+
"taxful_total_price" : 72.0
198+
}
199+
]
200+
----

0 commit comments

Comments
 (0)