diff --git a/docs/reference/setup/opentelemetry.md b/docs/reference/setup/opentelemetry.md index 1db4156df..def439fc3 100644 --- a/docs/reference/setup/opentelemetry.md +++ b/docs/reference/setup/opentelemetry.md @@ -60,6 +60,16 @@ ElasticsearchClient esClient = ElasticsearchClient.of(b -> b esClient.close(); ``` +## Capturing the {{es}} cluster name [opentelemetry-cluster-name] + +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. + +The source of the cluster name depends on your deployment: + +* **Elastic Cloud**: Automatically populates the cluster's canonical, globally unique ID (extracted from the `X-Found-Handling-Cluster` proxy header). +* **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. + + ## Configuring the OpenTelemetry instrumentation [_configuring_the_opentelemetry_instrumentation] You can configure the OpenTelemetry instrumentation either through Java System properties or Environment Variables. The following configuration options are available. diff --git a/java-client/src/main/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearch.java b/java-client/src/main/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearch.java index e9f300466..4781abac1 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearch.java +++ b/java-client/src/main/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearch.java @@ -96,6 +96,21 @@ public class OpenTelemetryForElasticsearch implements Instrumentation { private static final Log logger = LogFactory.getLog(OpenTelemetryForElasticsearch.class); + /** + * Cluster-name header added by the Elastic Cloud proxy; carries the cluster's canonical, + * globally-unique id. + */ + private static final String CLOUD_CLUSTER_HEADER = "X-Found-Handling-Cluster"; + + /** + * Cluster-name header emitted by self-managed Elasticsearch (9.6+) when + * {@code http.headers.cluster_name.enabled} is set; carries the configured {@code cluster.name}. + */ + private static final String ONPREM_CLUSTER_HEADER = "Elastic-Cluster-Name"; + + private static final AttributeKey DB_ES_CLUSTER_NAME = + AttributeKey.stringKey("db.elasticsearch.cluster.name"); + private final Tracer tracer; private final boolean captureSearchBody; @@ -241,6 +256,19 @@ public void afterReceivingHttpResponse(TransportHttpClient.Response httpResponse span.setAttribute(SERVER_PORT, uri.getPort()); span.setAttribute(SERVER_ADDRESS, uri.getHost()); span.setAttribute(HTTP_RESPONSE_STATUS_CODE, httpResponse.statusCode()); + + // Record the cluster identity as db.elasticsearch.cluster.name from the response headers — + // address-independent, so it survives load balancers, proxies and node lists. A deployment + // normally sends only one of these headers (Elastic Cloud sends X-Found-Handling-Cluster; + // self-managed sends Elastic-Cluster-Name when enabled). If both are present, the Cloud + // header takes precedence because it carries the canonical, globally-unique cluster id. + String clusterName = httpResponse.header(CLOUD_CLUSTER_HEADER); + if (clusterName == null || clusterName.isEmpty()) { + clusterName = httpResponse.header(ONPREM_CLUSTER_HEADER); + } + if (clusterName != null && !clusterName.isEmpty()) { + span.setAttribute(DB_ES_CLUSTER_NAME, clusterName); + } } } catch (RuntimeException e) { logger.debug("Failed capturing response information for the OpenTelemetry span.", e); diff --git a/java-client/src/test/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearchTest.java b/java-client/src/test/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearchTest.java index 6716c02b6..76b6f32f7 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearchTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/instrumentation/OpenTelemetryForElasticsearchTest.java @@ -64,6 +64,10 @@ public class OpenTelemetryForElasticsearchTest { private static final String INDEX = "test-index"; private static final String DOC_ID = "1234567"; + private static final AttributeKey DB_ES_CLUSTER_NAME = + AttributeKey.stringKey("db.elasticsearch.cluster.name"); + private static final String CLOUD_CLUSTER_HEADER = "X-Found-Handling-Cluster"; + private static final String ONPREM_CLUSTER_HEADER = "Elastic-Cluster-Name"; private static final String DOC_RESPONSE = "{\n" + " \"_index\": \"" + INDEX + "\",\n" + " \"_id\": \"" + DOC_ID + "\",\n" + @@ -155,9 +159,36 @@ private static void setupHttpServer() throws IOException { exchange.close(); }); + // handlers for the cluster-name capture scenarios: each index returns a specific + // combination of the cloud and on-prem cluster-name headers. + addDocHandler("cluster-cloud", "cloud-cluster-01", null); + addDocHandler("cluster-onprem", null, "onprem-cluster-01"); + addDocHandler("cluster-both", "cloud-cluster-01", "onprem-cluster-01"); + addDocHandler("cluster-none", null, null); + addDocHandler("cluster-empty-cloud", "", "onprem-cluster-01"); + addDocHandler("cluster-empty-both", "", ""); + httpServer.start(); } + // Registers a GET-document handler for the given index that stamps the given cluster-name headers on the + // response. A null value omits the header; an empty string sends the header with an empty value. + private static void addDocHandler(String index, String cloudHeaderValue, String onPremHeaderValue) { + httpServer.createContext("/" + index + "/_doc/" + DOC_ID, exchange -> { + exchange.getResponseHeaders().set("X-Elastic-Product", "Elasticsearch"); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + if (cloudHeaderValue != null) { + exchange.getResponseHeaders().set(CLOUD_CLUSTER_HEADER, cloudHeaderValue); + } + if (onPremHeaderValue != null) { + exchange.getResponseHeaders().set(ONPREM_CLUSTER_HEADER, onPremHeaderValue); + } + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().write(DOC_RESPONSE.getBytes()); + exchange.close(); + }); + } + private static void setupOTel() { Resource resource = Resource.getDefault() .merge(Resource.create(Attributes.of(SERVICE_NAME, "es-api-test"))); @@ -230,6 +261,54 @@ public void testAsyncSearchRequest() throws IOException, InterruptedException, T Assertions.assertNull(span.getAttributes().get(DbAttributes.DB_QUERY_TEXT)); } + @Test + public void testClusterNameFromCloudHeader() throws IOException { + // Elastic Cloud proxy header present -> captured directly. + client.get(r -> r.index("cluster-cloud").id(DOC_ID), Object.class); + SpanData span = spanExporter.getSpans().get(0); + Assertions.assertEquals("cloud-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME)); + } + + @Test + public void testClusterNameFromOnPremHeader() throws IOException { + // Self-managed header present (no cloud header) -> captured as fallback. + client.get(r -> r.index("cluster-onprem").id(DOC_ID), Object.class); + SpanData span = spanExporter.getSpans().get(0); + Assertions.assertEquals("onprem-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME)); + } + + @Test + public void testClusterNamePrefersCloudHeaderWhenBothPresent() throws IOException { + // Both headers present -> the cloud header wins (canonical, globally-unique id). + client.get(r -> r.index("cluster-both").id(DOC_ID), Object.class); + SpanData span = spanExporter.getSpans().get(0); + Assertions.assertEquals("cloud-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME)); + } + + @Test + public void testClusterNameAbsentWhenNoHeader() throws IOException { + // Neither header present -> the attribute is not stamped. + client.get(r -> r.index("cluster-none").id(DOC_ID), Object.class); + SpanData span = spanExporter.getSpans().get(0); + Assertions.assertNull(span.getAttributes().get(DB_ES_CLUSTER_NAME)); + } + + @Test + public void testClusterNameFallsBackToOnPremWhenCloudHeaderEmpty() throws IOException { + // Empty cloud header is treated as absent -> fall back to the on-prem header. + client.get(r -> r.index("cluster-empty-cloud").id(DOC_ID), Object.class); + SpanData span = spanExporter.getSpans().get(0); + Assertions.assertEquals("onprem-cluster-01", span.getAttributes().get(DB_ES_CLUSTER_NAME)); + } + + @Test + public void testClusterNameAbsentWhenHeadersEmpty() throws IOException { + // Both headers present but empty -> the attribute is not stamped. + client.get(r -> r.index("cluster-empty-both").id(DOC_ID), Object.class); + SpanData span = spanExporter.getSpans().get(0); + Assertions.assertNull(span.getAttributes().get(DB_ES_CLUSTER_NAME)); + } + private static class MockSpanExporter implements SpanExporter { private final List spans = new ArrayList();