Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions java/iot3/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
Expand All @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -309,7 +308,7 @@ public void publishMessage(String topic, String message, boolean retained, int q

// Inject the trace context into a map
Map<String, String> 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);
Expand Down Expand Up @@ -373,7 +372,7 @@ public String get(Map<String, String> carrier, String key) {
}
};

Context extractedContext = GlobalOpenTelemetry.getPropagators().getTextMapPropagator()
Context extractedContext = openTelemetryClient.getPropagators().getTextMapPropagator()
.extract(Context.current(), contextMap, getter);
SpanContext receivedSpanContext = Span.fromContext(extractedContext).getSpanContext();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <b>not</b> register itself with the
* process-wide {@code GlobalOpenTelemetry} singleton.
*
* <p>{@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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -119,9 +137,27 @@ 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.
*
* <p>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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -46,8 +46,8 @@ void startServer() throws IOException {
}

@AfterEach
void stopServer() throws IOException {
server.shutdown();
void stopServer() {
server.close();
}

// ── helpers ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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());

Expand All @@ -115,32 +116,34 @@ 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");
}

@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"));
Expand All @@ -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());

Expand All @@ -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());

Expand All @@ -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());

Expand All @@ -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());

Expand All @@ -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());

Expand All @@ -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());

Expand Down
Loading
Loading