Skip to content

Commit 696be4c

Browse files
authored
Override user agent header for otlp exporters (#593)
* Override user agent for otlp exporters * Add changelog * Remove accidentally added method * spotless
1 parent 03e4916 commit 696be4c

File tree

10 files changed

+315
-15
lines changed

10 files changed

+315
-15
lines changed

CHANGELOG.next-release.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
* Switched the default of `otel.exporter.otlp.metrics.temporality.preference` from `CUMULATIVE` to `DELTA` to improve dashboarding experience with Kibana. If you want to restore the previous behaviour, you can manually override `otel.exporter.otlp.metrics.temporality.preference` to `CUMULATIVE` via JVM-properties or environment variables. - #583
2+
* Set elastic-specific User-Agent header for OTLP exporters - #593

custom/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies {
1717
}
1818

1919
compileOnly("io.opentelemetry:opentelemetry-sdk")
20+
compileOnly("io.opentelemetry:opentelemetry-exporter-otlp")
2021
compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi")
2122
compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api")
2223
compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-tooling")

custom/src/main/java/co/elastic/otel/ElasticAutoConfigurationCustomizerProvider.java

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,9 @@ public class ElasticAutoConfigurationCustomizerProvider
6262

6363
@Override
6464
public void customize(AutoConfigurationCustomizer autoConfiguration) {
65-
autoConfiguration.addMetricExporterCustomizer(
66-
(metricexporter, configProperties) ->
67-
BlockableMetricExporter.createCustomInstance(metricexporter));
68-
autoConfiguration.addSpanExporterCustomizer(
69-
(spanExporter, configProperties) ->
70-
BlockableSpanExporter.createCustomInstance(spanExporter));
71-
autoConfiguration.addLogRecordExporterCustomizer(
72-
(logExporter, configProperties) ->
73-
BlockableLogRecordExporter.createCustomInstance(logExporter));
65+
// Order is important: configureExporterUserAgentHeaders needs access to the unwrapped exporters
66+
configureExporterUserAgentHeaders(autoConfiguration);
67+
configureBlockableExporters(autoConfiguration);
7468

7569
autoConfiguration.addPropertiesCustomizer(
7670
ElasticAutoConfigurationCustomizerProvider::propertiesCustomizer);
@@ -83,6 +77,29 @@ public void customize(AutoConfigurationCustomizer autoConfiguration) {
8377
autoConfiguration.addResourceCustomizer(resourceProviders());
8478
}
8579

80+
private void configureExporterUserAgentHeaders(AutoConfigurationCustomizer autoConfiguration) {
81+
autoConfiguration.addSpanExporterCustomizer(
82+
(spanExporter, configProperties) ->
83+
ElasticUserAgentHeader.configureIfPossible(spanExporter));
84+
autoConfiguration.addMetricExporterCustomizer(
85+
(metricExporter, configProperties) ->
86+
ElasticUserAgentHeader.configureIfPossible(metricExporter));
87+
autoConfiguration.addLogRecordExporterCustomizer(
88+
(logExporter, configProperties) -> ElasticUserAgentHeader.configureIfPossible(logExporter));
89+
}
90+
91+
private static void configureBlockableExporters(AutoConfigurationCustomizer autoConfiguration) {
92+
autoConfiguration.addMetricExporterCustomizer(
93+
(metricexporter, configProperties) ->
94+
BlockableMetricExporter.createCustomInstance(metricexporter));
95+
autoConfiguration.addSpanExporterCustomizer(
96+
(spanExporter, configProperties) ->
97+
BlockableSpanExporter.createCustomInstance(spanExporter));
98+
autoConfiguration.addLogRecordExporterCustomizer(
99+
(logExporter, configProperties) ->
100+
BlockableLogRecordExporter.createCustomInstance(logExporter));
101+
}
102+
86103
static Map<String, String> propertiesCustomizer(ConfigProperties configProperties) {
87104
Map<String, String> config = new HashMap<>();
88105

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package co.elastic.otel;
20+
21+
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter;
22+
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter;
23+
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
24+
import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter;
25+
import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter;
26+
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
27+
import io.opentelemetry.javaagent.tooling.AgentVersion;
28+
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
29+
import io.opentelemetry.sdk.metrics.export.MetricExporter;
30+
import io.opentelemetry.sdk.trace.export.SpanExporter;
31+
32+
public class ElasticUserAgentHeader {
33+
34+
private static final String HEADER_NAME = "User-Agent";
35+
private static final String GRPC_VALUE = "elastic-otlp-grpc-java/" + AgentVersion.VERSION;
36+
private static final String HTTP_VALUE = "elastic-otlp-http-java/" + AgentVersion.VERSION;
37+
38+
public static SpanExporter configureIfPossible(SpanExporter spanExporter) {
39+
if (spanExporter instanceof OtlpGrpcSpanExporter) {
40+
return ((OtlpGrpcSpanExporter) spanExporter)
41+
.toBuilder().addHeader(HEADER_NAME, GRPC_VALUE).build();
42+
} else if (spanExporter instanceof OtlpHttpSpanExporter) {
43+
return ((OtlpHttpSpanExporter) spanExporter)
44+
.toBuilder().addHeader(HEADER_NAME, HTTP_VALUE).build();
45+
}
46+
return spanExporter;
47+
}
48+
49+
public static MetricExporter configureIfPossible(MetricExporter metricExporter) {
50+
if (metricExporter instanceof OtlpGrpcMetricExporter) {
51+
return ((OtlpGrpcMetricExporter) metricExporter)
52+
.toBuilder().addHeader(HEADER_NAME, GRPC_VALUE).build();
53+
} else if (metricExporter instanceof OtlpHttpMetricExporter) {
54+
return ((OtlpHttpMetricExporter) metricExporter)
55+
.toBuilder().addHeader(HEADER_NAME, HTTP_VALUE).build();
56+
}
57+
return metricExporter;
58+
}
59+
60+
public static LogRecordExporter configureIfPossible(LogRecordExporter logExporter) {
61+
if (logExporter instanceof OtlpGrpcLogRecordExporter) {
62+
return ((OtlpGrpcLogRecordExporter) logExporter)
63+
.toBuilder().addHeader(HEADER_NAME, GRPC_VALUE).build();
64+
} else if (logExporter instanceof OtlpHttpLogRecordExporter) {
65+
return ((OtlpHttpLogRecordExporter) logExporter)
66+
.toBuilder().addHeader(HEADER_NAME, HTTP_VALUE).build();
67+
}
68+
return logExporter;
69+
}
70+
}

gradle/libs.versions.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ autoservice-annotations = { group = "com.google.auto.service", name = "auto-serv
4646
assertj-core = "org.assertj:assertj-core:3.27.3"
4747
awaitility = "org.awaitility:awaitility:4.3.0"
4848
findbugs-jsr305 = "com.google.code.findbugs:jsr305:3.0.2"
49-
wiremock = "com.github.tomakehurst:wiremock-jre8:2.35.2"
49+
wiremockjre8 = "com.github.tomakehurst:wiremock-jre8:2.35.2"
50+
wiremock = "org.wiremock:wiremock:3.12.1"
5051
testcontainers = "org.testcontainers:testcontainers:1.20.6"
5152
logback = "ch.qos.logback:logback-classic:1.5.18"
5253
jackson = "com.fasterxml.jackson.core:jackson-databind:2.18.3"

instrumentation/openai-client-instrumentation/testing-common/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ dependencies {
1111
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.3")
1212
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3")
1313
implementation("org.slf4j:slf4j-simple:2.0.17")
14-
implementation(catalog.wiremock)
14+
implementation(catalog.wiremockjre8)
1515
}

smoke-tests/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dependencies {
2323
testImplementation(catalog.okhttp)
2424
testImplementation(catalog.opentelemetryProto)
2525
testImplementation(catalog.awaitility)
26+
testImplementation(catalog.wiremock)
2627
testImplementation("io.opentelemetry:opentelemetry-api")
2728

2829
testImplementation(catalog.logback)

smoke-tests/src/test/java/com/example/javaagent/smoketest/SmokeTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ abstract class SmokeTest {
7474
protected static OkHttpClient client = OkHttpUtils.client();
7575

7676
private static final Network network = Network.newNetwork();
77-
private static final String agentPath =
77+
public static final String AGENT_PATH =
7878
System.getProperty("io.opentelemetry.smoketest.agent.shadowJar.path");
7979

8080
// keep track of all started containers in case they aren't properly stopped
@@ -113,7 +113,7 @@ protected static GenericContainer<?> startTarget(
113113
new GenericContainer<>(image)
114114
.withNetwork(network)
115115
.withLogConsumer(new Slf4jLogConsumer(logger))
116-
.withCopyFileToContainer(MountableFile.forHostPath(agentPath), JAVAAGENT_JAR_PATH)
116+
.withCopyFileToContainer(MountableFile.forHostPath(AGENT_PATH), JAVAAGENT_JAR_PATH)
117117

118118
// batch span processor: very small batch size for testing
119119
.withEnv("OTEL_BSP_MAX_EXPORT_BATCH", "1")

smoke-tests/src/test/java/com/example/javaagent/smoketest/TestAppSmokeTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424

2525
public class TestAppSmokeTest extends SmokeTest {
2626

27-
private static final String TEST_APP_IMAGE =
27+
public static final String TEST_APP_IMAGE =
2828
"docker.elastic.co/open-telemetry/elastic-otel-java/smoke-test/test-app:latest";
29-
private static final int PORT = 8080;
29+
public static final int PORT = 8080;
3030

3131
private static GenericContainer<?> target;
3232

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package com.example.javaagent.smoketest;
20+
21+
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
22+
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
23+
import static com.github.tomakehurst.wiremock.client.WireMock.post;
24+
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
25+
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
26+
import static com.github.tomakehurst.wiremock.client.WireMock.verify;
27+
import static org.awaitility.Awaitility.await;
28+
29+
import com.github.tomakehurst.wiremock.WireMockServer;
30+
import java.io.FileInputStream;
31+
import java.time.Duration;
32+
import java.util.Objects;
33+
import java.util.function.Consumer;
34+
import java.util.jar.Attributes;
35+
import java.util.jar.JarInputStream;
36+
import java.util.jar.Manifest;
37+
import org.junit.jupiter.api.AfterAll;
38+
import org.junit.jupiter.api.AfterEach;
39+
import org.junit.jupiter.api.BeforeAll;
40+
import org.junit.jupiter.api.Test;
41+
import org.slf4j.Logger;
42+
import org.slf4j.LoggerFactory;
43+
import org.testcontainers.Testcontainers;
44+
import org.testcontainers.containers.GenericContainer;
45+
import org.testcontainers.containers.output.Slf4jLogConsumer;
46+
import org.testcontainers.containers.wait.strategy.Wait;
47+
import org.testcontainers.utility.MountableFile;
48+
49+
public class UserAgentHeaderTest {
50+
51+
private static final Logger logger = LoggerFactory.getLogger(UserAgentHeaderTest.class);
52+
53+
private static final String AGENT_VERSION = extractVersion(SmokeTest.AGENT_PATH);
54+
55+
private static WireMockServer wireMock;
56+
57+
@BeforeAll
58+
public static void startWireMock() {
59+
wireMock = new WireMockServer();
60+
wireMock.start();
61+
}
62+
63+
@AfterAll
64+
public static void stopWireMock() {
65+
wireMock.stop();
66+
}
67+
68+
@AfterEach
69+
public void resetWiremock() {
70+
wireMock.resetAll();
71+
}
72+
73+
private String wiremockHostFromContainer() {
74+
return "host.testcontainers.internal:" + wireMock.port();
75+
}
76+
77+
@Test
78+
public void verifyHttpExporterAgentHeaders() {
79+
wireMock.stubFor(
80+
post(urlEqualTo("/v1/traces"))
81+
.willReturn(
82+
aResponse()
83+
.withStatus(200)
84+
.withHeader("Content-Type", "application/json")
85+
.withBody("{\"status\": \"ok\"}")));
86+
87+
wireMock.stubFor(
88+
post(urlEqualTo("/v1/metrics"))
89+
.willReturn(
90+
aResponse()
91+
.withStatus(200)
92+
.withHeader("Content-Type", "application/json")
93+
.withBody("{\"status\": \"ok\"}")));
94+
95+
wireMock.stubFor(
96+
post(urlEqualTo("/v1/logs"))
97+
.willReturn(
98+
aResponse()
99+
.withStatus(200)
100+
.withHeader("Content-Type", "application/json")
101+
.withBody("{\"status\": \"ok\"}")));
102+
103+
try (GenericContainer<?> container =
104+
runTestApp(
105+
cont ->
106+
cont.withEnv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
107+
.withEnv(
108+
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://" + wiremockHostFromContainer()))) {
109+
110+
await()
111+
.atMost(Duration.ofSeconds(10))
112+
.untilAsserted(
113+
() -> {
114+
// Traces ands logs are generated via the container health check
115+
verify(
116+
postRequestedFor(urlEqualTo("/v1/traces"))
117+
.withHeader(
118+
"User-Agent", equalTo("elastic-otlp-http-java/" + AGENT_VERSION)));
119+
verify(
120+
postRequestedFor(urlEqualTo("/v1/metrics"))
121+
.withHeader(
122+
"User-Agent", equalTo("elastic-otlp-http-java/" + AGENT_VERSION)));
123+
verify(
124+
postRequestedFor(urlEqualTo("/v1/logs"))
125+
.withHeader(
126+
"User-Agent", equalTo("elastic-otlp-http-java/" + AGENT_VERSION)));
127+
});
128+
}
129+
}
130+
131+
@Test
132+
public void verifyGrpcExporterAgentHeaders() {
133+
134+
// These responses are not correct for GRPC, but we don't care - we only care about the request
135+
// headers
136+
wireMock.stubFor(
137+
post(urlEqualTo("/opentelemetry.proto.collector.trace.v1.TraceService/Export"))
138+
.willReturn(aResponse().withStatus(200)));
139+
wireMock.stubFor(
140+
post(urlEqualTo("/opentelemetry.proto.collector.metrics.v1.MetricsService/Export"))
141+
.willReturn(aResponse().withStatus(200)));
142+
wireMock.stubFor(
143+
post(urlEqualTo("/opentelemetry.proto.collector.logs.v1.LogsService/Export"))
144+
.willReturn(aResponse().withStatus(200)));
145+
146+
try (GenericContainer<?> container =
147+
runTestApp(
148+
cont ->
149+
cont.withEnv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
150+
.withEnv(
151+
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://" + wiremockHostFromContainer()))) {
152+
153+
await()
154+
.atMost(Duration.ofSeconds(10))
155+
.untilAsserted(
156+
() -> {
157+
// Traces ands logs are generated via the container health check
158+
verify(
159+
postRequestedFor(
160+
urlEqualTo(
161+
"/opentelemetry.proto.collector.trace.v1.TraceService/Export"))
162+
.withHeader(
163+
"User-Agent", equalTo("elastic-otlp-grpc-java/" + AGENT_VERSION)));
164+
verify(
165+
postRequestedFor(
166+
urlEqualTo(
167+
"/opentelemetry.proto.collector.metrics.v1.MetricsService/Export"))
168+
.withHeader(
169+
"User-Agent", equalTo("elastic-otlp-grpc-java/" + AGENT_VERSION)));
170+
verify(
171+
postRequestedFor(
172+
urlEqualTo("/opentelemetry.proto.collector.logs.v1.LogsService/Export"))
173+
.withHeader(
174+
"User-Agent", equalTo("elastic-otlp-grpc-java/" + AGENT_VERSION)));
175+
});
176+
}
177+
}
178+
179+
private GenericContainer<?> runTestApp(Consumer<GenericContainer<?>> customizer) {
180+
Testcontainers.exposeHostPorts(wireMock.port());
181+
GenericContainer<?> target =
182+
new GenericContainer<>(TestAppSmokeTest.TEST_APP_IMAGE)
183+
.withLogConsumer(new Slf4jLogConsumer(logger))
184+
.withCopyFileToContainer(MountableFile.forHostPath(SmokeTest.AGENT_PATH), "/agent.jar")
185+
.withEnv("JAVA_TOOL_OPTIONS", JavaExecutable.jvmAgentArgument("/agent.jar"))
186+
// speed up exports
187+
.withEnv("OTEL_BSP_MAX_EXPORT_BATCH", "1")
188+
.withEnv("OTEL_BSP_SCHEDULE_DELAY", "10")
189+
.withEnv("OTEL_BLRP_MAX_EXPORT_BATCH", "1")
190+
.withEnv("OTEL_BLRP_SCHEDULE_DELAY", "10")
191+
.withEnv("OTEL_METRIC_EXPORT_INTERVAL", "10")
192+
// use grpc endpoint as default is now http/protobuf with agent 2.x
193+
.withExposedPorts(TestAppSmokeTest.PORT)
194+
.waitingFor(Wait.forHttp("/health").forPort(TestAppSmokeTest.PORT));
195+
customizer.accept(target);
196+
target.start();
197+
return target;
198+
}
199+
200+
private static String extractVersion(String agentJarPath) {
201+
try (JarInputStream jarStream = new JarInputStream(new FileInputStream(agentJarPath))) {
202+
Manifest mf = jarStream.getManifest();
203+
Attributes attributes = mf.getMainAttributes();
204+
return Objects.requireNonNull(attributes.getValue("Implementation-Version"));
205+
} catch (Exception e) {
206+
throw new IllegalStateException(e);
207+
}
208+
}
209+
}

0 commit comments

Comments
 (0)