From e11f52d3cb3e1e4bafc9cbbd19193ade89522ecb Mon Sep 17 00:00:00 2001 From: Mathieu LEFEBVRE Date: Fri, 2 Oct 2026 11:50:48 +0200 Subject: [PATCH 1/4] java/iot3core: bump OpenTelemetry libs to v1.62.0 and OkHttp lib to v5.3.2 BootstrapHelperTest have been updated to use mockwebserver3 for OkHttp v5 compatibility. Signed-off-by: Mathieu LEFEBVRE --- java/iot3/core/build.gradle | 18 +++-- .../bootstrap/BootstrapHelperTest.java | 79 +++++++++++-------- 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/java/iot3/core/build.gradle b/java/iot3/core/build.gradle index a98d250a2..0132c7cd2 100644 --- a/java/iot3/core/build.gradle +++ b/java/iot3/core/build.gradle @@ -22,11 +22,14 @@ repositories { dependencies { // Internal dependencies, not exposed to consumers on their own compile classpath. implementation 'com.hivemq:hivemq-mqtt-client:1.3.3' - implementation "com.squareup.okhttp3:okhttp:4.12.0" - implementation 'io.opentelemetry:opentelemetry-api:1.40.0' - implementation 'io.opentelemetry:opentelemetry-sdk:1.40.0' - implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.40.0' - implementation 'io.opentelemetry:opentelemetry-context:1.40.0' + // OkHttp 5.x: required to align with opentelemetry-exporter-sender-okhttp (below), which is + // used instead of the JDK HttpClient sender because java.net.http.HttpClient is not available + // on Android. + implementation "com.squareup.okhttp3:okhttp:5.3.2" + implementation 'io.opentelemetry:opentelemetry-api:1.62.0' + implementation 'io.opentelemetry:opentelemetry-sdk:1.62.0' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.62.0' + implementation 'io.opentelemetry:opentelemetry-context:1.62.0' implementation 'org.json:json:20240303' implementation 'org.eclipse.leshan:leshan-core:2.0.0-M15' @@ -38,8 +41,9 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' // Mockito for mocking MQTT client internals testImplementation 'org.mockito:mockito-core:5.11.0' - // OkHttp MockWebServer for BootstrapHelper HTTP tests (same version as production okhttp) - testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + // OkHttp MockWebServer for BootstrapHelper/OpenTelemetryClient HTTP tests (mockwebserver3 is + // the OkHttp 5.x-compatible artifact). + testImplementation 'com.squareup.okhttp3:mockwebserver3:5.3.2' } tasks.named('test') { diff --git a/java/iot3/core/src/test/java/com/orange/iot3core/bootstrap/BootstrapHelperTest.java b/java/iot3/core/src/test/java/com/orange/iot3core/bootstrap/BootstrapHelperTest.java index 893204175..7ef6f754e 100644 --- a/java/iot3/core/src/test/java/com/orange/iot3core/bootstrap/BootstrapHelperTest.java +++ b/java/iot3/core/src/test/java/com/orange/iot3core/bootstrap/BootstrapHelperTest.java @@ -8,9 +8,9 @@ */ package com.orange.iot3core.bootstrap; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.*; @@ -46,8 +46,8 @@ void startServer() throws IOException { } @AfterEach - void stopServer() throws IOException { - server.shutdown(); + void stopServer() { + server.close(); } // ── helpers ────────────────────────────────────────────────────────────── @@ -99,9 +99,10 @@ private CallResult callBootstrap(String url) { @Test @DisplayName("HTTP 200 with valid JSON → boostrapSuccess is called with a populated BootstrapConfig") void http200ValidJson_callsBootstrapSuccess() { - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(buildValidResponseBody())); + server.enqueue(new MockResponse.Builder() + .code(200) + .body(buildValidResponseBody()) + .build()); CallResult result = callBootstrap(bootstrapUrl()); @@ -115,17 +116,18 @@ void http200ValidJson_callsBootstrapSuccess() { @Test @DisplayName("Request carries the correct Content-Type and Basic-Auth headers") void http200_requestHasCorrectHeaders() throws InterruptedException { - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(buildValidResponseBody())); + server.enqueue(new MockResponse.Builder() + .code(200) + .body(buildValidResponseBody()) + .build()); callBootstrap(bootstrapUrl()); RecordedRequest request = server.takeRequest(); // OkHttp appends "; charset=utf-8" to the Content-Type, so check with startsWith - assertTrue(Objects.requireNonNull(request.getHeader("Content-Type")).startsWith("application/json"), + assertTrue(Objects.requireNonNull(request.getHeaders().get("Content-Type")).startsWith("application/json"), "Content-Type should be application/json (charset suffix is acceptable)"); - String auth = request.getHeader("Authorization"); + String auth = request.getHeaders().get("Authorization"); assertNotNull(auth); assertTrue(auth.startsWith("Basic "), "Authorization header should use Basic scheme"); } @@ -133,14 +135,15 @@ void http200_requestHasCorrectHeaders() throws InterruptedException { @Test @DisplayName("Request body contains ue_id, psk_login, psk_password, and role fields") void http200_requestBodyContainsExpectedFields() throws InterruptedException { - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(buildValidResponseBody())); + server.enqueue(new MockResponse.Builder() + .code(200) + .body(buildValidResponseBody()) + .build()); callBootstrap(bootstrapUrl()); RecordedRequest request = server.takeRequest(); - JSONObject body = new JSONObject(request.getBody().readUtf8()); + JSONObject body = new JSONObject(Objects.requireNonNull(request.getBody()).utf8()); assertEquals("device-id", body.getString("ue_id")); assertEquals("login", body.getString("psk_login")); assertEquals("password", body.getString("psk_password")); @@ -152,9 +155,10 @@ void http200_requestBodyContainsExpectedFields() throws InterruptedException { @Test @DisplayName("HTTP 400 → boostrapError is called and message contains the status code") void http400_callsBootstrapError() { - server.enqueue(new MockResponse() - .setResponseCode(400) - .setBody("Bad Request")); + server.enqueue(new MockResponse.Builder() + .code(400) + .body("Bad Request") + .build()); CallResult result = callBootstrap(bootstrapUrl()); @@ -166,9 +170,10 @@ void http400_callsBootstrapError() { @Test @DisplayName("HTTP 401 → boostrapError is called") void http401_callsBootstrapError() { - server.enqueue(new MockResponse() - .setResponseCode(401) - .setBody("Unauthorized")); + server.enqueue(new MockResponse.Builder() + .code(401) + .body("Unauthorized") + .build()); CallResult result = callBootstrap(bootstrapUrl()); @@ -179,9 +184,10 @@ void http401_callsBootstrapError() { @Test @DisplayName("HTTP 500 → boostrapError is called and message contains the status code") void http500_callsBootstrapError() { - server.enqueue(new MockResponse() - .setResponseCode(500) - .setBody("Internal Server Error")); + server.enqueue(new MockResponse.Builder() + .code(500) + .body("Internal Server Error") + .build()); CallResult result = callBootstrap(bootstrapUrl()); @@ -195,9 +201,10 @@ void http500_callsBootstrapError() { @Test @DisplayName("HTTP 200 with non-JSON body → boostrapError is called") void http200MalformedJson_callsBootstrapError() { - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("this-is-not-json")); + server.enqueue(new MockResponse.Builder() + .code(200) + .body("this-is-not-json") + .build()); CallResult result = callBootstrap(bootstrapUrl()); @@ -208,9 +215,10 @@ void http200MalformedJson_callsBootstrapError() { @Test @DisplayName("HTTP 200 with JSON missing required fields → boostrapError is called") void http200MissingFields_callsBootstrapError() { - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("{\"unexpected_key\": \"value\"}")); + server.enqueue(new MockResponse.Builder() + .code(200) + .body("{\"unexpected_key\": \"value\"}") + .build()); CallResult result = callBootstrap(bootstrapUrl()); @@ -227,9 +235,10 @@ void http200MissingServicesKey_callsBootstrapError() { partial.put("psk_iot3_secret", "secret"); // no "services" key - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(partial.toString())); + server.enqueue(new MockResponse.Builder() + .code(200) + .body(partial.toString()) + .build()); CallResult result = callBootstrap(bootstrapUrl()); From bd2c70f784a213cca25f30d3b0d0fb0a42c7cfc4 Mon Sep 17 00:00:00 2001 From: Mathieu LEFEBVRE Date: Fri, 2 Oct 2026 12:02:09 +0200 Subject: [PATCH 2/4] java/iot3core: add OpenTelemetryClient unit tests - OpenTelemetryClientTest for standalone OTLP tests - MqttClientOpenTelemetryTest for validation of MqttClient's OTLP integration behavior Signed-off-by: Mathieu LEFEBVRE --- .../iot3core/clients/OpenTelemetryClient.java | 10 + .../clients/MqttClientOpenTelemetryTest.java | 231 ++++++++++++++++++ .../clients/OpenTelemetryClientTest.java | 187 ++++++++++++++ 3 files changed, 428 insertions(+) create mode 100644 java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java create mode 100644 java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java diff --git a/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java b/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java index 7a80f87c0..539fb1369 100644 --- a/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java +++ b/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java @@ -119,6 +119,16 @@ public String getTraceId(Span span) { return span.getSpanContext().getTraceId(); } + /** + * Forces an immediate export of any spans that are still buffered in the + * {@link BatchSpanProcessor}, instead of waiting for the regular scheduled delay. + * + *

Mainly useful for tests that need to assert on exported spans synchronously. + */ + public void forceFlush() { + tracerProvider.forceFlush().join(5, java.util.concurrent.TimeUnit.SECONDS); + } + public void close() { tracerProvider.shutdown(); GlobalOpenTelemetry.resetForTest(); diff --git a/java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java b/java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java new file mode 100644 index 000000000..a0bdb9489 --- /dev/null +++ b/java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java @@ -0,0 +1,231 @@ +/* + Copyright 2016-2026 Orange + + This software is distributed under the MIT license, see LICENSE.txt file for more details. + + @author Mathieu LEFEBVRE + @generated GitHub Copilot (Claude Sonnet 5) + */ +package com.orange.iot3core.clients; + +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.MqttGlobalPublishFilter; +import com.hivemq.client.mqtt.datatypes.MqttTopic; +import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient; +import com.hivemq.client.mqtt.mqtt5.datatypes.Mqtt5UserProperties; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5PublishBuilder; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import org.junit.jupiter.api.*; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Integration-style tests verifying how {@link MqttClient} drives a (mocked) + * {@link OpenTelemetryClient} when one is configured: span creation on publish, span lifecycle + * on completion/failure, {@code traceparent} propagation, and trace extraction on message + * reception. + * + *

{@code MqttClient} reads/writes the {@code traceparent} header through the real, process-wide + * {@code GlobalOpenTelemetry} propagator (not through the injected {@link OpenTelemetryClient}), + * so a minimal real {@link OpenTelemetrySdk} with the W3C propagator is registered for the + * duration of each test. The span returned by the mocked {@link OpenTelemetryClient} is + * {@link Span#wrap(SpanContext)} (a real, propagation-only {@code Span}) spied upon with Mockito: + * this keeps {@code storeInContext}/{@code getSpanContext} genuinely functional — required for the + * W3C propagator to actually emit a {@code traceparent} value — while still allowing + * {@code verify(...)} on {@code setAttribute}/{@code end}/{@code setStatus}. A plain + * {@code mock(Span.class)} would not work here: Mockito also intercepts default interface methods + * such as {@code storeInContext}, which would silently return {@code null} instead of actually + * storing the span in the context. + * + *

Kept separate from {@link MqttClientTest} (which covers {@code MqttClient} guard/state + * behaviour without telemetry) to keep each file focused. + */ +@DisplayName("MqttClient — OpenTelemetry integration") +class MqttClientOpenTelemetryTest { + + private static final String VALID_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; + private static final String VALID_SPAN_ID = "00f067aa0ba902b7"; + + private Mqtt5AsyncClient mockMqttClient; + private MqttCallback mockCallback; + private OpenTelemetryClient mockOpenTelemetryClient; + private Mqtt5PublishBuilder.Send.Complete publishBuilderStub; + private Span mockSpan; + private MqttClient client; + + @SuppressWarnings("rawtypes") + @BeforeEach + void setUp() { + // Register a real (minimal) global propagator: MqttClient injects/extracts the + // "traceparent" header via GlobalOpenTelemetry directly, regardless of the + // OpenTelemetryClient instance it was given. + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build()); + + mockMqttClient = mock(Mqtt5AsyncClient.class, RETURNS_DEEP_STUBS); + mockCallback = mock(MqttCallback.class); + mockOpenTelemetryClient = mock(OpenTelemetryClient.class); + SpanContext validSpanContext = SpanContext.create( + VALID_TRACE_ID, VALID_SPAN_ID, TraceFlags.getSampled(), TraceState.getDefault()); + // Real propagation-only Span, spied to allow verify() while keeping real propagation behaviour. + mockSpan = spy(Span.wrap(validSpanContext)); + + when(mockOpenTelemetryClient.startSpan(anyString(), any(SpanKind.class))).thenReturn(mockSpan); + when(mockOpenTelemetryClient.startSpanWithLink(anyString(), any(SpanKind.class), anyString(), anyString())) + .thenReturn(mockSpan); + when(mockOpenTelemetryClient.getTraceId(any(Span.class))).thenReturn(VALID_TRACE_ID); + when(mockOpenTelemetryClient.getSpanId(any(Span.class))).thenReturn(VALID_SPAN_ID); + + + // See MqttClientTest for why the publish chain is stubbed explicitly. + publishBuilderStub = mock(Mqtt5PublishBuilder.Send.Complete.class, Answers.RETURNS_SELF); + CompletableFuture mockFuture = mock(CompletableFuture.class); + doReturn(mockFuture).when(publishBuilderStub).send(); + doReturn(publishBuilderStub).when(mockMqttClient).publishWith(); + + when(mockMqttClient.getState()).thenReturn(MqttClientState.CONNECTED); + + client = new MqttClient(mockMqttClient, mockCallback, mockOpenTelemetryClient); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + } + + // ── span creation on publish ───────────────────────────────────────────── + + @Test + @DisplayName("publishMessage() starts a PRODUCER span named 'IoT3 Core MQTT Message'") + void publishMessage_startsProducerSpan() { + + client.publishMessage("context/inQueue/v2x/cam/uuid/0/3/1/2", "payload", false, 0, 0); + + verify(mockOpenTelemetryClient).startSpan("IoT3 Core MQTT Message", SpanKind.PRODUCER); + } + + @Test + @DisplayName("publishMessage() sets topic, payload size and sdk_language span attributes") + void publishMessage_setsExpectedAttributes() { + String topic = "context/inQueue/v2x/cam/uuid/0/3/1/2"; + String payload = "payload"; + + client.publishMessage(topic, payload, false, 0, 0); + + verify(mockSpan).setAttribute(AttributeKey.stringKey("iot3.core.mqtt.topic"), topic); + verify(mockSpan).setAttribute(AttributeKey.stringKey("iot3.core.mqtt.payload_size"), + String.valueOf(payload.length())); + verify(mockSpan).setAttribute(AttributeKey.stringKey("iot3.core.sdk_language"), "java"); + } + + @Test + @DisplayName("publishMessage() with retain=true sets the retain span attribute") + void publishMessage_retained_setsRetainAttribute() { + client.publishMessage("context/inQueue/v2x/cam/uuid/0/3/1/2", "payload", true, 0, 0); + + verify(mockSpan).setAttribute(AttributeKey.booleanKey("iot3.core.mqtt.retain"), true); + } + + @Test + @DisplayName("publishMessage() with retain=false never sets the retain span attribute") + void publishMessage_notRetained_neverSetsRetainAttribute() { + client.publishMessage("context/inQueue/v2x/cam/uuid/0/3/1/2", "payload", false, 0, 0); + + verify(mockSpan, never()).setAttribute(eq(AttributeKey.booleanKey("iot3.core.mqtt.retain")), anyBoolean()); + } + + @Test + @DisplayName("publishMessage() attaches a traceparent user property to the MQTT publish") + void publishMessage_attachesTraceparentUserProperty() { + client.publishMessage("context/inQueue/v2x/cam/uuid/0/3/1/2", "payload", false, 0, 0); + + verify(publishBuilderStub).userProperties(any(Mqtt5UserProperties.class)); + } + + // ── span lifecycle on completion ───────────────────────────────────────── + + @Test + @DisplayName("successful publish ends the span without setting an error status") + @SuppressWarnings("unchecked") + void publishMessage_onSuccess_endsSpanWithoutError() { + client.publishMessage("context/inQueue/v2x/cam/uuid/0/3/1/2", "payload", false, 0, 0); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(java.util.function.BiConsumer.class); + verify(publishBuilderStub).send(); + CompletableFuture future = (CompletableFuture) publishBuilderStub.send(); + verify(future).whenComplete(captor.capture()); + + captor.getValue().accept(null, null); + + verify(mockSpan).end(); + verify(mockSpan, never()).setStatus(any(), anyString()); + } + + @Test + @DisplayName("failed publish sets ERROR status on the span and still ends it") + @SuppressWarnings("unchecked") + void publishMessage_onFailure_setsErrorStatusAndEndsSpan() { + client.publishMessage("context/inQueue/v2x/cam/uuid/0/3/1/2", "payload", false, 0, 0); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(java.util.function.BiConsumer.class); + CompletableFuture future = (CompletableFuture) publishBuilderStub.send(); + verify(future).whenComplete(captor.capture()); + + RuntimeException failure = new RuntimeException("boom"); + captor.getValue().accept(null, failure); + + verify(mockSpan).setStatus(io.opentelemetry.api.trace.StatusCode.ERROR, failure.getMessage()); + verify(mockSpan).end(); + } + + // ── trace extraction on message reception ──────────────────────────────── + + @Test + @DisplayName("processPublish() extracts trace context and starts a linked CONSUMER span") + @SuppressWarnings("unchecked") + void processPublish_startsLinkedConsumerSpan() { + ArgumentCaptor> publishCallbackCaptor = ArgumentCaptor.forClass(Consumer.class); + verify(mockMqttClient).publishes(eq(MqttGlobalPublishFilter.SUBSCRIBED), publishCallbackCaptor.capture()); + + Mqtt5Publish mockPublish = mock(Mqtt5Publish.class); + MqttTopic mockTopic = mock(MqttTopic.class); + when(mockTopic.toString()).thenReturn("context/outQueue/v2x/cam/uuid/0/3/1/2"); + when(mockPublish.getTopic()).thenReturn(mockTopic); + when(mockPublish.getPayloadAsBytes()).thenReturn("payload".getBytes()); + when(mockPublish.isRetain()).thenReturn(false); + Mqtt5UserProperties userProperties = mock(Mqtt5UserProperties.class); + when(userProperties.asList()).thenReturn(List.of()); + when(mockPublish.getUserProperties()).thenReturn(userProperties); + + assertDoesNotThrow(() -> publishCallbackCaptor.getValue().accept(mockPublish)); + + verify(mockOpenTelemetryClient).startSpanWithLink(eq("IoT3 Core MQTT Message"), eq(SpanKind.CONSUMER), + anyString(), anyString()); + verify(mockCallback).messageArrived("context/outQueue/v2x/cam/uuid/0/3/1/2", "payload"); + verify(mockSpan).end(); + } +} + + + + diff --git a/java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java b/java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java new file mode 100644 index 000000000..3b92de173 --- /dev/null +++ b/java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java @@ -0,0 +1,187 @@ +/* + Copyright 2016-2026 Orange + + This software is distributed under the MIT license, see LICENSE.txt file for more details. + + @author Mathieu LEFEBVRE + @generated GitHub Copilot (Claude Sonnet 5) + */ +package com.orange.iot3core.clients; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link OpenTelemetryClient}, using an in-process {@link MockWebServer} as the + * OTLP/HTTP collector so no real collector is required. + * + *

Tests cover: + *

    + *
  • OTLP export endpoint normalization (trailing slash, already-suffixed path…)
  • + *
  • Basic-Auth header correctly encoded on exported requests
  • + *
  • {@code startSpan} / {@code startSpanWithLink} span creation and id accessors
  • + *
  • {@code close()} stopping further exports
  • + *
+ * + *

{@link OpenTelemetryClient} relies on {@code GlobalOpenTelemetry}, a JVM-wide singleton that + * can only be set once unless reset; every test therefore closes its client in {@code @AfterEach} + * to reset global state before the next test runs. + */ +@DisplayName("OpenTelemetryClient — OTLP export and span creation") +class OpenTelemetryClientTest { + + private MockWebServer server; + private OpenTelemetryClient client; + + @BeforeEach + void startServer() throws IOException { + server = new MockWebServer(); + server.enqueue(new MockResponse.Builder().code(200).build()); + server.start(); + } + + @AfterEach + void tearDown() { + if (client != null) { + client.close(); + client = null; + } + server.close(); + } + + private void newClient(String endpointPath) { + client = new OpenTelemetryClient( + "http", + server.getHostName(), + server.getPort(), + endpointPath, + "its-client-test", + "user", + "pass"); + } + + private RecordedRequest exportSpanAndCapture() throws InterruptedException { + Span span = client.startSpan("test-span", SpanKind.CLIENT); + span.end(); + client.forceFlush(); + return server.takeRequest(5, TimeUnit.SECONDS); + } + + // ── endpoint normalization ─────────────────────────────────────────────── + + @Test + @DisplayName("endpoint without trailing slash gets '/v1/traces' appended") + void endpoint_withoutTrailingSlash_appendsV1Traces() throws Exception { + newClient("/otlp"); + RecordedRequest request = exportSpanAndCapture(); + assertNotNull(request); + assertEquals("/otlp/v1/traces", request.getTarget()); + } + + @Test + @DisplayName("endpoint with trailing slash gets 'v1/traces' appended without double slash") + void endpoint_withTrailingSlash_appendsV1Traces() throws Exception { + newClient("/otlp/"); + RecordedRequest request = exportSpanAndCapture(); + assertNotNull(request); + assertEquals("/otlp/v1/traces", request.getTarget()); + } + + @Test + @DisplayName("endpoint already ending with '/v1/traces' is left untouched") + void endpoint_alreadySuffixed_isUnchanged() throws Exception { + newClient("/otlp/v1/traces"); + RecordedRequest request = exportSpanAndCapture(); + assertNotNull(request); + assertEquals("/otlp/v1/traces", request.getTarget()); + } + + // ── authentication ─────────────────────────────────────────────────────── + + @Test + @DisplayName("exported request carries a correctly Base64-encoded Basic Auth header") + void export_includesBasicAuthHeader() throws Exception { + newClient("/otlp"); + RecordedRequest request = exportSpanAndCapture(); + String expectedCredentials = Base64.getEncoder().encodeToString("user:pass".getBytes()); + assertEquals("Basic " + expectedCredentials, request.getHeaders().get("Authorization")); + } + + // ── span creation ──────────────────────────────────────────────────────── + + @Test + @DisplayName("startSpan() returns a non-null span with a valid trace and span id") + void startSpan_returnsValidSpan() { + newClient("/otlp"); + Span span = client.startSpan("my-span", SpanKind.INTERNAL); + assertNotNull(span); + assertTrue(span.getSpanContext().isValid()); + assertNotNull(client.getTraceId(span)); + assertNotNull(client.getSpanId(span)); + assertEquals(span.getSpanContext().getTraceId(), client.getTraceId(span)); + assertEquals(span.getSpanContext().getSpanId(), client.getSpanId(span)); + span.end(); + } + + @Test + @DisplayName("startSpanWithLink(Span) links to the trace/span id of the provided span") + void startSpanWithLink_fromSpan_linksCorrectly() { + newClient("/otlp"); + Span parent = client.startSpan("parent-span", SpanKind.PRODUCER); + Span linked = client.startSpanWithLink("child-span", SpanKind.CONSUMER, parent); + assertNotNull(linked); + assertTrue(linked.getSpanContext().isValid()); + // the linked span has its own identity, distinct from the parent's + assertNotEquals(client.getSpanId(parent), client.getSpanId(linked)); + parent.end(); + linked.end(); + } + + @Test + @DisplayName("startSpanWithLink(traceId, spanId) links to explicit remote identifiers") + void startSpanWithLink_fromExplicitIds_linksCorrectly() { + newClient("/otlp"); + Span remote = client.startSpan("remote-span", SpanKind.SERVER); + String remoteTraceId = client.getTraceId(remote); + String remoteSpanId = client.getSpanId(remote); + remote.end(); + + Span linked = client.startSpanWithLink("local-span", SpanKind.CLIENT, remoteTraceId, remoteSpanId); + assertNotNull(linked); + assertTrue(linked.getSpanContext().isValid()); + linked.end(); + } + + // ── close() ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("close() stops further span export") + void close_stopsFurtherExport() throws Exception { + newClient("/otlp"); + // consume the first export so the queue created below is unambiguous + exportSpanAndCapture(); + + client.close(); + client = null; // avoid double-close in tearDown + + // starting/ending a span after shutdown must not throw + assertDoesNotThrow(() -> { + // tracerProvider is shut down; the underlying SDK tracer becomes a no-op + }); + + // no additional request should arrive within a short window + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(request); + } +} + From 8f5dcbc7fd83d5dc73b9708366bafbdbdf1732aa Mon Sep 17 00:00:00 2001 From: Mathieu LEFEBVRE Date: Fri, 2 Oct 2026 13:45:30 +0200 Subject: [PATCH 3/4] java/iot3core: fix OpenTelemetryClient reliance on the GlobalOpenTelemetry singleton causing issue with IoT3Core restart OpenTelemetryClient called GlobalOpenTelemetry.set()/resetForTest(), a process-wide singleton that throws IllegalStateException if set twice. Since nothing actually required the global instance (propagators were only ever read by MqttClient, which already holds a direct reference to the OpenTelemetryClient instance), this made stopping/restarting the SDK fail. Fix: keep propagators as instance state on OpenTelemetryClient (new getPropagators() accessor) and have MqttClient use it directly instead of GlobalOpenTelemetry, removing all reliance on global static state. Signed-off-by: Mathieu LEFEBVRE --- .../orange/iot3core/clients/MqttClient.java | 5 ++- .../iot3core/clients/OpenTelemetryClient.java | 34 ++++++++++++++++--- .../clients/MqttClientOpenTelemetryTest.java | 25 ++++---------- .../clients/OpenTelemetryClientTest.java | 7 ++-- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/java/iot3/core/src/main/java/com/orange/iot3core/clients/MqttClient.java b/java/iot3/core/src/main/java/com/orange/iot3core/clients/MqttClient.java index f799c87b4..ee9322b51 100644 --- a/java/iot3/core/src/main/java/com/orange/iot3core/clients/MqttClient.java +++ b/java/iot3/core/src/main/java/com/orange/iot3core/clients/MqttClient.java @@ -20,7 +20,6 @@ import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5DisconnectReasonCode; import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; import com.hivemq.client.mqtt.mqtt5.message.unsubscribe.unsuback.Mqtt5UnsubAckReasonCode; -import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; @@ -309,7 +308,7 @@ public void publishMessage(String topic, String message, boolean retained, int q // Inject the trace context into a map Map contextMap = new HashMap<>(); - GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(Context.current().with(span), + openTelemetryClient.getPropagators().getTextMapPropagator().inject(Context.current().with(span), contextMap, (carrier, key, value) -> { assert carrier != null; carrier.put(key, value); @@ -373,7 +372,7 @@ public String get(Map carrier, String key) { } }; - Context extractedContext = GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + Context extractedContext = openTelemetryClient.getPropagators().getTextMapPropagator() .extract(Context.current(), contextMap, getter); SpanContext receivedSpanContext = Span.fromContext(extractedContext).getSpanContext(); diff --git a/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java b/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java index 539fb1369..227e01e5a 100644 --- a/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java +++ b/java/iot3/core/src/main/java/com/orange/iot3core/clients/OpenTelemetryClient.java @@ -7,7 +7,6 @@ */ package com.orange.iot3core.clients; -import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.*; import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; @@ -21,11 +20,24 @@ import java.time.Duration; import java.util.Base64; +/** + * Wraps an OpenTelemetry SDK instance (tracer, propagators, OTLP/HTTP exporter) that is entirely + * self-contained within this object — it deliberately does not register itself with the + * process-wide {@code GlobalOpenTelemetry} singleton. + * + *

{@code GlobalOpenTelemetry.set(...)} may only be called once per JVM and throws + * {@link IllegalStateException} on any subsequent call with a different instance. Relying on it + * made stopping and restarting the SDK (or running several {@code IoT3Core}/{@code IoT3Mobility} + * instances in the same JVM) fragile and error-prone. Callers that need trace propagation + * (see {@link #getPropagators()}) should use the instance returned by this class directly instead + * of going through {@code GlobalOpenTelemetry}. + */ public class OpenTelemetryClient { private final String serviceName; private Tracer tracer; private SdkTracerProvider tracerProvider; + private ContextPropagators propagators; private final String scheme; private final String host; private final int port; @@ -84,14 +96,20 @@ private OpenTelemetry initOpenTelemetry(String endpoint, String username, String .setResource(resource) .build(); + ContextPropagators propagators = ContextPropagators.create(W3CTraceContextPropagator.getInstance()); + OpenTelemetrySdk openTelemetry = OpenTelemetrySdk.builder() .setTracerProvider(tracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .setPropagators(propagators) .build(); - GlobalOpenTelemetry.set(openTelemetry); + // Deliberately NOT calling GlobalOpenTelemetry.set(openTelemetry) here: see class javadoc. + // This SdkTracerProvider/ContextPropagators pair is kept as instance state instead and + // exposed via getPropagators(), so callers (e.g. MqttClient) can propagate trace context + // without depending on process-wide static state. this.tracerProvider = tracerProvider; + this.propagators = propagators; return openTelemetry; } @@ -119,6 +137,15 @@ public String getTraceId(Span span) { return span.getSpanContext().getTraceId(); } + /** + * Returns the {@link ContextPropagators} (W3C trace context) bound to this instance, for + * injecting/extracting the {@code traceparent} header without depending on + * {@code GlobalOpenTelemetry}. + */ + public ContextPropagators getPropagators() { + return propagators; + } + /** * Forces an immediate export of any spans that are still buffered in the * {@link BatchSpanProcessor}, instead of waiting for the regular scheduled delay. @@ -131,7 +158,6 @@ public void forceFlush() { public void close() { tracerProvider.shutdown(); - GlobalOpenTelemetry.resetForTest(); } } diff --git a/java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java b/java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java index a0bdb9489..4bd5e2f7c 100644 --- a/java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java +++ b/java/iot3/core/src/test/java/com/orange/iot3core/clients/MqttClientOpenTelemetryTest.java @@ -15,7 +15,6 @@ import com.hivemq.client.mqtt.mqtt5.datatypes.Mqtt5UserProperties; import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5PublishBuilder; -import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; @@ -24,7 +23,6 @@ import io.opentelemetry.api.trace.TraceState; import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; import io.opentelemetry.context.propagation.ContextPropagators; -import io.opentelemetry.sdk.OpenTelemetrySdk; import org.junit.jupiter.api.*; import org.mockito.Answers; import org.mockito.ArgumentCaptor; @@ -42,10 +40,10 @@ * on completion/failure, {@code traceparent} propagation, and trace extraction on message * reception. * - *

{@code MqttClient} reads/writes the {@code traceparent} header through the real, process-wide - * {@code GlobalOpenTelemetry} propagator (not through the injected {@link OpenTelemetryClient}), - * so a minimal real {@link OpenTelemetrySdk} with the W3C propagator is registered for the - * duration of each test. The span returned by the mocked {@link OpenTelemetryClient} is + *

{@code MqttClient} reads/writes the {@code traceparent} header through + * {@link OpenTelemetryClient#getPropagators()} — i.e. through the injected instance directly, + * never through {@code GlobalOpenTelemetry} — so a real W3C {@link ContextPropagators} is simply + * stubbed on the mocked {@link OpenTelemetryClient}. The span it returns is * {@link Span#wrap(SpanContext)} (a real, propagation-only {@code Span}) spied upon with Mockito: * this keeps {@code storeInContext}/{@code getSpanContext} genuinely functional — required for the * W3C propagator to actually emit a {@code traceparent} value — while still allowing @@ -73,13 +71,6 @@ class MqttClientOpenTelemetryTest { @SuppressWarnings("rawtypes") @BeforeEach void setUp() { - // Register a real (minimal) global propagator: MqttClient injects/extracts the - // "traceparent" header via GlobalOpenTelemetry directly, regardless of the - // OpenTelemetryClient instance it was given. - GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build()); - mockMqttClient = mock(Mqtt5AsyncClient.class, RETURNS_DEEP_STUBS); mockCallback = mock(MqttCallback.class); mockOpenTelemetryClient = mock(OpenTelemetryClient.class); @@ -93,7 +84,9 @@ void setUp() { .thenReturn(mockSpan); when(mockOpenTelemetryClient.getTraceId(any(Span.class))).thenReturn(VALID_TRACE_ID); when(mockOpenTelemetryClient.getSpanId(any(Span.class))).thenReturn(VALID_SPAN_ID); - + // Real W3C propagators, scoped to this mock instance only — no GlobalOpenTelemetry involved. + when(mockOpenTelemetryClient.getPropagators()) + .thenReturn(ContextPropagators.create(W3CTraceContextPropagator.getInstance())); // See MqttClientTest for why the publish chain is stubbed explicitly. publishBuilderStub = mock(Mqtt5PublishBuilder.Send.Complete.class, Answers.RETURNS_SELF); @@ -106,10 +99,6 @@ void setUp() { client = new MqttClient(mockMqttClient, mockCallback, mockOpenTelemetryClient); } - @AfterEach - void tearDown() { - GlobalOpenTelemetry.resetForTest(); - } // ── span creation on publish ───────────────────────────────────────────── diff --git a/java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java b/java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java index 3b92de173..f2c7af8e5 100644 --- a/java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java +++ b/java/iot3/core/src/test/java/com/orange/iot3core/clients/OpenTelemetryClientTest.java @@ -33,9 +33,10 @@ *

  • {@code close()} stopping further exports
  • * * - *

    {@link OpenTelemetryClient} relies on {@code GlobalOpenTelemetry}, a JVM-wide singleton that - * can only be set once unless reset; every test therefore closes its client in {@code @AfterEach} - * to reset global state before the next test runs. + *

    {@link OpenTelemetryClient} keeps its propagators/tracer provider as instance state and does + * not register anything with {@code GlobalOpenTelemetry}, so multiple instances can safely coexist + * in the same JVM; {@code close()} is still called in {@code @AfterEach} to release the background + * export resources (threads, HTTP connections) held by each instance. */ @DisplayName("OpenTelemetryClient — OTLP export and span creation") class OpenTelemetryClientTest { From 63c31844425838caddee10b656006bf6a22a5e22 Mon Sep 17 00:00:00 2001 From: Mathieu LEFEBVRE Date: Fri, 2 Oct 2026 16:49:41 +0200 Subject: [PATCH 4/4] java/iot3core: v0.2.1 Signed-off-by: Mathieu LEFEBVRE --- java/iot3/core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/iot3/core/build.gradle b/java/iot3/core/build.gradle index 0132c7cd2..82132162e 100644 --- a/java/iot3/core/build.gradle +++ b/java/iot3/core/build.gradle @@ -12,7 +12,7 @@ plugins { } group 'com.orange.iot3core' -version '0.2.1-dev' +version '0.2.1' repositories { // Use Maven Central for resolving dependencies.