Skip to content

Commit fc4d8d6

Browse files
authored
ESQL: Enable visualizing a query profile (#124361)
To understand query performance, we often peruse the output of `_query`-requests run with `"profile": true`. This is difficult when the query runs in a large cluster with many nodes and shards, or in case of CCQ. This adds an option to visualize a query using Chromium's/Chrome's builtin `about:tracing` - or, for even better visuals and querying the different drivers via SQL, perfetto (c.f. https://ui.perfetto.dev/). To use, save the JSON output of a query run with `"profile": true` to a file, like `output.json` and then invoke the following Gradle task: ``` ./gradlew x-pack:plugin:esql:tools:parseProfile --args='~/output.json ~/parsed_profile.json' ``` Either open `about:tracing` in Chromium/Chrome ![image](https://github.com/user-attachments/assets/75e17ddf-f032-4aa1-bf3e-61b985b4e0b6) Or head over to https://ui.perfetto.dev (build locally in case of potentially sensitive data in the profille): ![image](https://github.com/user-attachments/assets/b3372b7d-fbec-45aa-a68c-b24e62a8c704) Every slice is a driver, the colors indicating the ratio of cpu time over total time. - In Perfetto, essentials like duration, cpu duration, timestamp and a few others can be queried via SQL - this allows e.g. querying for all drivers that spent more than 50% of their time waiting and other fun things. ![image](https://github.com/user-attachments/assets/4a0ab2ce-3585-4953-b2eb-71991777b3fa) - Details about a driver, esp. which operators it ran, are available when clicking the driver's slice. ![image](https://github.com/user-attachments/assets/e1c0b30d-0a31-468c-9ff4-27ca452716fc)
1 parent b2c1c4e commit fc4d8d6

File tree

6 files changed

+365
-3
lines changed

6 files changed

+365
-3
lines changed

x-pack/plugin/esql/qa/server/single-node/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ apply plugin: 'elasticsearch.internal-test-artifact'
66
dependencies {
77
javaRestTestImplementation project(xpackModule('esql:qa:testFixtures'))
88
javaRestTestImplementation project(xpackModule('esql:qa:server'))
9+
javaRestTestImplementation project(xpackModule('esql:tools'))
910
yamlRestTestImplementation project(xpackModule('esql:qa:server'))
1011

1112
javaRestTestImplementation('org.apache.arrow:arrow-vector:16.1.0')

x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/RestEsqlIT.java

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
1010
import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters;
1111

12+
import org.apache.http.HttpEntity;
1213
import org.apache.http.util.EntityUtils;
1314
import org.apache.lucene.search.DocIdSetIterator;
1415
import org.elasticsearch.Build;
@@ -17,38 +18,52 @@
1718
import org.elasticsearch.client.ResponseException;
1819
import org.elasticsearch.common.io.Streams;
1920
import org.elasticsearch.common.settings.Settings;
21+
import org.elasticsearch.common.xcontent.XContentHelper;
2022
import org.elasticsearch.test.ListMatcher;
2123
import org.elasticsearch.test.MapMatcher;
2224
import org.elasticsearch.test.TestClustersThreadFilter;
2325
import org.elasticsearch.test.cluster.ElasticsearchCluster;
2426
import org.elasticsearch.test.cluster.LogType;
27+
import org.elasticsearch.xcontent.XContentBuilder;
2528
import org.elasticsearch.xcontent.XContentType;
29+
import org.elasticsearch.xcontent.json.JsonXContent;
2630
import org.elasticsearch.xpack.esql.qa.rest.RestEsqlTestCase;
31+
import org.elasticsearch.xpack.esql.tools.ProfileParser;
2732
import org.hamcrest.Matchers;
2833
import org.junit.Assert;
2934
import org.junit.ClassRule;
3035

36+
import java.io.ByteArrayInputStream;
37+
import java.io.ByteArrayOutputStream;
3138
import java.io.IOException;
3239
import java.io.InputStream;
3340
import java.nio.charset.StandardCharsets;
3441
import java.util.ArrayList;
3542
import java.util.Arrays;
43+
import java.util.HashSet;
3644
import java.util.List;
3745
import java.util.Locale;
3846
import java.util.Map;
47+
import java.util.Set;
3948

4049
import static org.elasticsearch.test.ListMatcher.matchesList;
4150
import static org.elasticsearch.test.MapMatcher.assertMap;
4251
import static org.elasticsearch.test.MapMatcher.matchesMap;
52+
import static org.elasticsearch.xpack.esql.qa.rest.RestEsqlTestCase.Mode.SYNC;
53+
import static org.elasticsearch.xpack.esql.tools.ProfileParser.parseProfile;
54+
import static org.elasticsearch.xpack.esql.tools.ProfileParser.readProfileFromResponse;
4355
import static org.hamcrest.Matchers.any;
4456
import static org.hamcrest.Matchers.containsInAnyOrder;
4557
import static org.hamcrest.Matchers.containsString;
4658
import static org.hamcrest.Matchers.either;
59+
import static org.hamcrest.Matchers.empty;
4760
import static org.hamcrest.Matchers.equalTo;
4861
import static org.hamcrest.Matchers.greaterThan;
62+
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
4963
import static org.hamcrest.Matchers.hasItem;
5064
import static org.hamcrest.Matchers.instanceOf;
5165
import static org.hamcrest.Matchers.not;
66+
import static org.hamcrest.Matchers.oneOf;
5267
import static org.hamcrest.Matchers.startsWith;
5368
import static org.hamcrest.core.Is.is;
5469

@@ -330,6 +345,116 @@ public void testProfile() throws IOException {
330345
}
331346
}
332347

348+
private final String PROCESS_NAME = "process_name";
349+
private final String THREAD_NAME = "thread_name";
350+
351+
@SuppressWarnings("unchecked")
352+
public void testProfileParsing() throws IOException {
353+
indexTimestampData(1);
354+
355+
RequestObjectBuilder builder = new RequestObjectBuilder(XContentType.JSON).query(fromIndex() + " | stats avg(value)").profile(true);
356+
Request request = prepareRequestWithOptions(builder, SYNC);
357+
HttpEntity response = performRequest(request).getEntity();
358+
359+
ProfileParser.Profile profile;
360+
try (InputStream responseContent = response.getContent()) {
361+
profile = readProfileFromResponse(responseContent);
362+
}
363+
364+
ByteArrayOutputStream os = new ByteArrayOutputStream();
365+
try (XContentBuilder jsonOutputBuilder = new XContentBuilder(JsonXContent.jsonXContent, os)) {
366+
parseProfile(profile, jsonOutputBuilder);
367+
}
368+
369+
// Read the written JSON again into a map, so we can make assertions on it
370+
ByteArrayInputStream profileJson = new ByteArrayInputStream(os.toByteArray());
371+
Map<String, Object> parsedProfile = XContentHelper.convertToMap(JsonXContent.jsonXContent, profileJson, true);
372+
373+
assertEquals("ns", parsedProfile.get("displayTimeUnit"));
374+
List<Map<String, Object>> events = (List<Map<String, Object>>) parsedProfile.get("traceEvents");
375+
// At least 1 metadata event to declare the node, and 2 events each for the data, node_reduce and final drivers, resp.
376+
assertThat(events.size(), greaterThanOrEqualTo(7));
377+
378+
String clusterName = "test-cluster";
379+
Set<String> expectedProcessNames = new HashSet<>();
380+
for (int i = 0; i < cluster.getNumNodes(); i++) {
381+
expectedProcessNames.add(clusterName + ":" + cluster.getName(i));
382+
}
383+
384+
int seenNodes = 0;
385+
int seenDrivers = 0;
386+
// Declaration of each node as a "process" via a metadata event (phase `ph` is `M`)
387+
// First event has to declare the first seen node.
388+
Map<String, Object> nodeMetadata = events.get(0);
389+
assertProcessMetadataForNextNode(nodeMetadata, expectedProcessNames, seenNodes++);
390+
391+
// The rest should be pairs of 2 events: first, a metadata event, declaring 1 "thread" per driver in the profile, then
392+
// a "complete" event (phase `ph` is `X`) with a timestamp, duration `dur`, thread duration `tdur` (cpu time) and additional
393+
// arguments obtained from the driver.
394+
// Except when run as part of the Serverless tests, which can involve more than 1 node - in which case, there will be more node
395+
// metadata events.
396+
for (int i = 1; i < events.size() - 1;) {
397+
String eventName = (String) events.get(i).get("name");
398+
assertTrue(Set.of(THREAD_NAME, PROCESS_NAME).contains(eventName));
399+
if (eventName.equals(THREAD_NAME)) {
400+
Map<String, Object> metadataEventForDriver = events.get(i);
401+
Map<String, Object> eventForDriver = events.get(i + 1);
402+
assertDriverData(metadataEventForDriver, eventForDriver, seenNodes, seenDrivers);
403+
i = i + 2;
404+
seenDrivers++;
405+
} else if (eventName.equals(PROCESS_NAME)) {
406+
Map<String, Object> metadataEventForNode = events.get(i);
407+
assertProcessMetadataForNextNode(metadataEventForNode, expectedProcessNames, seenNodes);
408+
i++;
409+
seenNodes++;
410+
}
411+
}
412+
}
413+
414+
@SuppressWarnings("unchecked")
415+
public void assertProcessMetadataForNextNode(Map<String, Object> nodeMetadata, Set<String> expectedNamesForNodes, int seenNodes) {
416+
assertEquals("M", nodeMetadata.get("ph"));
417+
assertEquals(PROCESS_NAME, nodeMetadata.get("name"));
418+
assertEquals(seenNodes, nodeMetadata.get("pid"));
419+
420+
Map<String, Object> nodeMetadataArgs = (Map<String, Object>) nodeMetadata.get("args");
421+
assertTrue(expectedNamesForNodes.contains((String) nodeMetadataArgs.get("name")));
422+
}
423+
424+
@SuppressWarnings("unchecked")
425+
public void assertDriverData(Map<String, Object> driverMetadata, Map<String, Object> driverEvent, int seenNodes, int seenDrivers) {
426+
assertEquals("M", driverMetadata.get("ph"));
427+
assertEquals(THREAD_NAME, driverMetadata.get("name"));
428+
assertTrue((int) driverMetadata.get("pid") < seenNodes);
429+
assertEquals(seenDrivers, driverMetadata.get("tid"));
430+
Map<String, Object> driverMetadataArgs = (Map<String, Object>) driverMetadata.get("args");
431+
String driverType = (String) driverMetadataArgs.get("name");
432+
assertThat(driverType, oneOf("data", "node_reduce", "final"));
433+
434+
assertEquals("X", driverEvent.get("ph"));
435+
assertThat((String) driverEvent.get("name"), startsWith(driverType));
436+
// Category used to implicitly colour-code and group drivers
437+
assertEquals(driverType, driverEvent.get("cat"));
438+
assertTrue((int) driverEvent.get("pid") < seenNodes);
439+
assertEquals(seenDrivers, driverEvent.get("tid"));
440+
long timestampMillis = (long) driverEvent.get("ts");
441+
double durationMicros = (double) driverEvent.get("dur");
442+
double cpuDurationMicros = (double) driverEvent.get("tdur");
443+
assertTrue(timestampMillis >= 0);
444+
assertTrue(durationMicros >= 0);
445+
assertTrue(cpuDurationMicros >= 0);
446+
assertTrue(durationMicros >= cpuDurationMicros);
447+
448+
// This should contain the essential information from a driver, like its operators, and will be just attached to the slice/
449+
// visible when clicking on it.
450+
Map<String, Object> driverSliceArgs = (Map<String, Object>) driverEvent.get("args");
451+
assertNotNull(driverSliceArgs.get("cpu_nanos"));
452+
assertNotNull(driverSliceArgs.get("took_nanos"));
453+
assertNotNull(driverSliceArgs.get("iterations"));
454+
assertNotNull(driverSliceArgs.get("sleeps"));
455+
assertThat(((List<String>) driverSliceArgs.get("operators")), not(empty()));
456+
}
457+
333458
public void testProfileOrdinalsGroupingOperator() throws IOException {
334459
assumeTrue("requires pragmas", Build.current().isSnapshot());
335460
indexTimestampData(1);

x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/RestEsqlTestCase.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,7 +1153,7 @@ public void testAsyncGetWithoutContentType() throws IOException {
11531153

11541154
}
11551155

1156-
static Request prepareRequestWithOptions(RequestObjectBuilder requestObject, Mode mode) throws IOException {
1156+
protected static Request prepareRequestWithOptions(RequestObjectBuilder requestObject, Mode mode) throws IOException {
11571157
requestObject.build();
11581158
Request request = prepareRequest(mode);
11591159
String mediaType = attachBody(requestObject, request);
@@ -1355,7 +1355,7 @@ private static HttpEntity performRequest(Request request, AssertWarnings assertW
13551355
return assertWarnings(performRequest(request), assertWarnings);
13561356
}
13571357

1358-
private static Response performRequest(Request request) throws IOException {
1358+
protected static Response performRequest(Request request) throws IOException {
13591359
Response response = client().performRequest(request);
13601360
if (shouldLog()) {
13611361
LOGGER.info("RESPONSE={}", response);

x-pack/plugin/esql/qa/testFixtures/build.gradle

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,3 @@ tasks.register("loadCsvSpecData", JavaExec) {
3939
classpath = sourceSets.main.runtimeClasspath
4040
mainClass = "org.elasticsearch.xpack.esql.CsvTestsDataLoader"
4141
}
42-
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
apply plugin: 'elasticsearch.java'
2+
3+
dependencies {
4+
implementation project(":libs:x-content")
5+
implementation project(':libs:logging')
6+
// Required for log4j, there's probably a more direct way to depend on it.
7+
implementation project(':test:framework')
8+
api "com.fasterxml.jackson.core:jackson-core:${versions.jackson}"
9+
api "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}"
10+
api "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
11+
}
12+
13+
/**
14+
* To visualize the profile of an ES|QL query, run the query with {@code "profile": true} then parse it with this task to import the profile into
15+
* Chromium's profile visualizer (type {@code about:tracing} in the URL bar) or into Perfetto (<a href=" https://ui.perfetto.dev/"/>;run Perfetto
16+
* locally if the profile might contain sensitive information, see instructions at
17+
* <a href="https://perfetto.dev/docs/contributing/build-instructions#ui-development"/>).
18+
*
19+
* eg.
20+
* ./gradlew x-pack:plugin:esql:tools:parseProfile --args='~/elasticsearch/query_output.json ~/elasticsearch/parsed_profile.json'
21+
*/
22+
tasks.register("parseProfile", JavaExec) {
23+
group = "Execution"
24+
description = "Parses the output of a query run with profile:true to be imported into the Chromium trace viewer (about:tracing) or Perfetto."
25+
classpath = sourceSets.main.runtimeClasspath
26+
mainClass = "org.elasticsearch.xpack.esql.tools.ProfileParser"
27+
}

0 commit comments

Comments
 (0)