Skip to content

Commit 501b49a

Browse files
Capture the Elasticsearch cluster name on OpenTelemetry spans from response headers (#1319)
* Capture Elasticsearch cluster name on OpenTelemetry spans from response headers Read the cluster name from the response headers and stamp it as db.elasticsearch.cluster.name on every client span: prefer the Elastic Cloud proxy header (X-Found-Handling-Cluster), and fall back to the Elastic-Cluster-Name header emitted by self-managed clusters (opt-in via http.headers.cluster_name.enabled). The capture is address-independent, so it survives load balancers and proxies, and needs no extra request. * Add tests for cluster-name capture on OpenTelemetry spans Cover all scenarios in OpenTelemetryForElasticsearchTest: cloud header (X-Found-Handling-Cluster) only, on-prem header (Elastic-Cluster-Name) only, both present (cloud preferred), neither present, empty cloud header falling back to on-prem, and both headers empty (attribute not stamped). * Document cluster-name capture in the OpenTelemetry guide Describe the db.elasticsearch.cluster.name span attribute captured from the X-Found-Handling-Cluster (Elastic Cloud) and Elastic-Cluster-Name (self-managed) response headers.
1 parent b8134c5 commit 501b49a

3 files changed

Lines changed: 117 additions & 0 deletions

File tree

‎docs/reference/setup/opentelemetry.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ ElasticsearchClient esClient = ElasticsearchClient.of(b -> b
6060
esClient.close();
6161
```
6262

63+
## Capturing the {{es}} cluster name [opentelemetry-cluster-name]
64+
65+
The built-in instrumentation automatically captures the `db.elasticsearch.cluster.name` span attribute whenever {{es}} includes the cluster name in its response headers. No client-side configuration is required.
66+
67+
The source of the cluster name depends on your deployment:
68+
69+
* **Elastic Cloud**: Automatically populates the cluster's canonical, globally unique ID (extracted from the `X-Found-Handling-Cluster` proxy header).
70+
* **Self-managed {{es}} (v9.6+)**: Populates the configured `cluster.name` (extracted from the `Elastic-Cluster-Name` header). To enable this header, set `http.headers.cluster_name.enabled: true` in your cluster settings.
71+
72+
6373
## Configuring the OpenTelemetry instrumentation [_configuring_the_opentelemetry_instrumentation]
6474

6575
You can configure the OpenTelemetry instrumentation either through Java System properties or Environment Variables. The following configuration options are available.

‎java-client/src/main/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearch.java‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,21 @@ public class OpenTelemetryForElasticsearch implements Instrumentation {
9696

9797
private static final Log logger = LogFactory.getLog(OpenTelemetryForElasticsearch.class);
9898

99+
/**
100+
* Cluster-name header added by the Elastic Cloud proxy; carries the cluster's canonical,
101+
* globally-unique id.
102+
*/
103+
private static final String CLOUD_CLUSTER_HEADER = "X-Found-Handling-Cluster";
104+
105+
/**
106+
* Cluster-name header emitted by self-managed Elasticsearch (9.6+) when
107+
* {@code http.headers.cluster_name.enabled} is set; carries the configured {@code cluster.name}.
108+
*/
109+
private static final String ONPREM_CLUSTER_HEADER = "Elastic-Cluster-Name";
110+
111+
private static final AttributeKey<String> DB_ES_CLUSTER_NAME =
112+
AttributeKey.stringKey("db.elasticsearch.cluster.name");
113+
99114
private final Tracer tracer;
100115
private final boolean captureSearchBody;
101116

@@ -241,6 +256,19 @@ public void afterReceivingHttpResponse(TransportHttpClient.Response httpResponse
241256
span.setAttribute(SERVER_PORT, uri.getPort());
242257
span.setAttribute(SERVER_ADDRESS, uri.getHost());
243258
span.setAttribute(HTTP_RESPONSE_STATUS_CODE, httpResponse.statusCode());
259+
260+
// Record the cluster identity as db.elasticsearch.cluster.name from the response headers —
261+
// address-independent, so it survives load balancers, proxies and node lists. A deployment
262+
// normally sends only one of these headers (Elastic Cloud sends X-Found-Handling-Cluster;
263+
// self-managed sends Elastic-Cluster-Name when enabled). If both are present, the Cloud
264+
// header takes precedence because it carries the canonical, globally-unique cluster id.
265+
String clusterName = httpResponse.header(CLOUD_CLUSTER_HEADER);
266+
if (clusterName == null || clusterName.isEmpty()) {
267+
clusterName = httpResponse.header(ONPREM_CLUSTER_HEADER);
268+
}
269+
if (clusterName != null && !clusterName.isEmpty()) {
270+
span.setAttribute(DB_ES_CLUSTER_NAME, clusterName);
271+
}
244272
}
245273
} catch (RuntimeException e) {
246274
logger.debug("Failed capturing response information for the OpenTelemetry span.", e);

‎java-client/src/test/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearchTest.java‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
public class OpenTelemetryForElasticsearchTest {
6565
private static final String INDEX = "test-index";
6666
private static final String DOC_ID = "1234567";
67+
private static final AttributeKey<String> DB_ES_CLUSTER_NAME =
68+
AttributeKey.stringKey("db.elasticsearch.cluster.name");
69+
private static final String CLOUD_CLUSTER_HEADER = "X-Found-Handling-Cluster";
70+
private static final String ONPREM_CLUSTER_HEADER = "Elastic-Cluster-Name";
6771
private static final String DOC_RESPONSE = "{\n" +
6872
" \"_index\": \"" + INDEX + "\",\n" +
6973
" \"_id\": \"" + DOC_ID + "\",\n" +
@@ -155,9 +159,36 @@ private static void setupHttpServer() throws IOException {
155159
exchange.close();
156160
});
157161

162+
// handlers for the cluster-name capture scenarios: each index returns a specific
163+
// combination of the cloud and on-prem cluster-name headers.
164+
addDocHandler("cluster-cloud", "cloud-cluster-01", null);
165+
addDocHandler("cluster-onprem", null, "onprem-cluster-01");
166+
addDocHandler("cluster-both", "cloud-cluster-01", "onprem-cluster-01");
167+
addDocHandler("cluster-none", null, null);
168+
addDocHandler("cluster-empty-cloud", "", "onprem-cluster-01");
169+
addDocHandler("cluster-empty-both", "", "");
170+
158171
httpServer.start();
159172
}
160173

174+
// Registers a GET-document handler for the given index that stamps the given cluster-name headers on the
175+
// response. A null value omits the header; an empty string sends the header with an empty value.
176+
private static void addDocHandler(String index, String cloudHeaderValue, String onPremHeaderValue) {
177+
httpServer.createContext("/" + index + "/_doc/" + DOC_ID, exchange -> {
178+
exchange.getResponseHeaders().set("X-Elastic-Product", "Elasticsearch");
179+
exchange.getResponseHeaders().set("Content-Type", "application/json");
180+
if (cloudHeaderValue != null) {
181+
exchange.getResponseHeaders().set(CLOUD_CLUSTER_HEADER, cloudHeaderValue);
182+
}
183+
if (onPremHeaderValue != null) {
184+
exchange.getResponseHeaders().set(ONPREM_CLUSTER_HEADER, onPremHeaderValue);
185+
}
186+
exchange.sendResponseHeaders(200, 0);
187+
exchange.getResponseBody().write(DOC_RESPONSE.getBytes());
188+
exchange.close();
189+
});
190+
}
191+
161192
private static void setupOTel() {
162193
Resource resource = Resource.getDefault()
163194
.merge(Resource.create(Attributes.of(SERVICE_NAME, "es-api-test")));
@@ -230,6 +261,54 @@ public void testAsyncSearchRequest() throws IOException, InterruptedException, T
230261
Assertions.assertNull(span.getAttributes().get(DbAttributes.DB_QUERY_TEXT));
231262
}
232263

264+
@Test
265+
public void testClusterNameFromCloudHeader() throws IOException {
266+
// Elastic Cloud proxy header present -> captured directly.
267+
client.get(r -> r.index("cluster-cloud").id(DOC_ID), Object.class);
268+
SpanData span = spanExporter.getSpans().get(0);
269+
Assertions.assertEquals("cloud-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME));
270+
}
271+
272+
@Test
273+
public void testClusterNameFromOnPremHeader() throws IOException {
274+
// Self-managed header present (no cloud header) -> captured as fallback.
275+
client.get(r -> r.index("cluster-onprem").id(DOC_ID), Object.class);
276+
SpanData span = spanExporter.getSpans().get(0);
277+
Assertions.assertEquals("onprem-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME));
278+
}
279+
280+
@Test
281+
public void testClusterNamePrefersCloudHeaderWhenBothPresent() throws IOException {
282+
// Both headers present -> the cloud header wins (canonical, globally-unique id).
283+
client.get(r -> r.index("cluster-both").id(DOC_ID), Object.class);
284+
SpanData span = spanExporter.getSpans().get(0);
285+
Assertions.assertEquals("cloud-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME));
286+
}
287+
288+
@Test
289+
public void testClusterNameAbsentWhenNoHeader() throws IOException {
290+
// Neither header present -> the attribute is not stamped.
291+
client.get(r -> r.index("cluster-none").id(DOC_ID), Object.class);
292+
SpanData span = spanExporter.getSpans().get(0);
293+
Assertions.assertNull(span.getAttributes().get(DB_ES_CLUSTER_NAME));
294+
}
295+
296+
@Test
297+
public void testClusterNameFallsBackToOnPremWhenCloudHeaderEmpty() throws IOException {
298+
// Empty cloud header is treated as absent -> fall back to the on-prem header.
299+
client.get(r -> r.index("cluster-empty-cloud").id(DOC_ID), Object.class);
300+
SpanData span = spanExporter.getSpans().get(0);
301+
Assertions.assertEquals("onprem-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME));
302+
}
303+
304+
@Test
305+
public void testClusterNameAbsentWhenHeadersEmpty() throws IOException {
306+
// Both headers present but empty -> the attribute is not stamped.
307+
client.get(r -> r.index("cluster-empty-both").id(DOC_ID), Object.class);
308+
SpanData span = spanExporter.getSpans().get(0);
309+
Assertions.assertNull(span.getAttributes().get(DB_ES_CLUSTER_NAME));
310+
}
311+
233312
private static class MockSpanExporter implements SpanExporter {
234313

235314
private final List<SpanData> spans = new ArrayList();

0 commit comments

Comments
 (0)