Add boot42-otel module: Spring Boot 4.2.0-RC1 OpenTelemetry conventions, shared OTLP settings and MDC keys vs 4.1.2

This commit is contained in:
2026-10-22 18:37:10 +00:00
parent 917d39fb65
commit 8252cba4c5
29 changed files with 958 additions and 0 deletions
+1
View File
@@ -46,6 +46,7 @@ files.
| [`project-structure/`](project-structure) | [Spring Boot Project Structure: Package-by-Layer vs Package-by-Feature](https://ankurm.com/spring-boot-project-structure/) | the same application written package-by-layer and package-by-feature with one HTTP API, the real source trees, 9 of 9 types public by layer vs 3 of 15 by feature, and the `javac` error that makes a feature boundary a compiler rule instead of a convention | | [`project-structure/`](project-structure) | [Spring Boot Project Structure: Package-by-Layer vs Package-by-Feature](https://ankurm.com/spring-boot-project-structure/) | the same application written package-by-layer and package-by-feature with one HTTP API, the real source trees, 9 of 9 types public by layer vs 3 of 15 by feature, and the `javac` error that makes a feature boundary a compiler rule instead of a convention |
| [`crud-mysql/`](crud-mysql) | [Spring Boot CRUD REST API with MySQL and Spring Data JPA](https://ankurm.com/spring-boot-crud-rest-api-mysql/) | a complete book API on a real MySQL 8.4.6 server with Flyway owning the schema and Hibernate set to `validate`, `ProblemDetail` errors with per-field validation messages, a duplicate ISBN caught by the database's unique key and mapped to `409` on create and on update, MySQL's own `Duplicate entry` message, and the SQL behind every request | | [`crud-mysql/`](crud-mysql) | [Spring Boot CRUD REST API with MySQL and Spring Data JPA](https://ankurm.com/spring-boot-crud-rest-api-mysql/) | a complete book API on a real MySQL 8.4.6 server with Flyway owning the schema and Hibernate set to `validate`, `ProblemDetail` errors with per-field validation messages, a duplicate ISBN caught by the database's unique key and mapped to `409` on create and on update, MySQL's own `Duplicate entry` message, and the SQL behind every request |
| [`boot42-rc1-checklist/`](boot42-rc1-checklist) | [Spring Framework 7.1 and Spring Boot 4.2: What's Coming and How to Prepare](https://ankurm.com/spring-boot-4-2-spring-framework-7-1-upgrade-checklist/) | each 4.2 milestone change run on 4.2.0-RC1 and on 4.1.2: `@DefaultValue` placeholders resolved, five new Kafka admin keys that 4.1.2 silently ignores, embedded LDAP SSL failing without a bundle, the properties migrator's real report (and the removed key it misses), and the `RestTemplate` removal warnings from the compiler | | [`boot42-rc1-checklist/`](boot42-rc1-checklist) | [Spring Framework 7.1 and Spring Boot 4.2: What's Coming and How to Prepare](https://ankurm.com/spring-boot-4-2-spring-framework-7-1-upgrade-checklist/) | each 4.2 milestone change run on 4.2.0-RC1 and on 4.1.2: `@DefaultValue` placeholders resolved, five new Kafka admin keys that 4.1.2 silently ignores, embedded LDAP SSL failing without a bundle, the properties migrator's real report (and the removed key it misses), and the `RestTemplate` removal warnings from the compiler |
| [`boot42-otel/`](boot42-otel) | [Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys](https://ankurm.com/spring-boot-4-2-opentelemetry-semantic-conventions-shared-otlp-mdc/) | one request measured under each `management.observations.conventions` value (the documented `open-telemetry` fails to start on RC1, `opentelemetry` works), meter, span and Prometheus names before and after, `management.opentelemetry.otlp.*` verified against a local OTLP receiver (paths, merged headers, gzip, per-signal overrides), custom MDC keys, and the LongTaskTimer that is no longer on by default |
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in `core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
collapsible sections inside the articles themselves. Where their captured output sits is not collapsible sections inside the articles themselves. Where their captured output sits is not
+20
View File
@@ -0,0 +1,20 @@
# boot42-otel
Companion module for the ankurm.com article *Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys*.
Everything runs against **Spring Boot 4.2.0-RC1** (a release candidate, **not GA**) and against **4.1.2** (GA) as the baseline. JDK 25.
| Path | What it is |
|---|---|
| `pom.xml` / `pom-41.xml` | the same project on 4.2.0-RC1 and on 4.1.2 |
| `src/main/.../OtelApplication` | one endpoint, `GET /hello/{name}`, that logs a line |
| `src/test/.../OtlpSink` | a 60-line OTLP/HTTP receiver: records path, headers and payload strings of every export request |
| `src/test/.../ConventionsTest` | meters and span names for each `management.observations.conventions` value, including the start-up failure RC1 gives for `open-telemetry` |
| `src/test/.../ConventionSpellingsTest` | seven spellings of the conventions value, each started for real: which start and which meter they produce |
| `src/test/.../SharedOtlpTest` | `management.opentelemetry.otlp.*`: paths, merged headers, gzip, and signal-specific overrides |
| `src/test/.../MdcAndLongTaskTimerTest` | custom MDC keys, and the active-observation LongTaskTimer that is no longer on by default |
| `src/test/.../PrometheusNamesTest` | the `http_server_*` names a dashboard query sees under each convention |
| `output/` | every transcript quoted in the article; `./scripts/run-all.sh` regenerates them |
Run: `./scripts/run-all.sh`. `ConventionsTest` pins the RC1 behaviour on purpose; when a later build changes it, that test fails and the article needs updating.
There is deliberately no `docs/` folder: the explanations live in the article.
@@ -0,0 +1,37 @@
# 01-conventions-metrics-and-spans (Spring Boot 4.1.2, JDK 25)
== management.observations.conventions=(property not set)
-- meters whose name contains 'http':
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
http.server.requests.active [exception=none, method=GET, outcome=SUCCESS, status=200, uri=UNKNOWN]
-- span-related strings in the OTLP trace payload (1 request(s)):
/hello/ankurJ
/hello/{name}z
http get /hello/{name}0
== management.observations.conventions=micrometer
-- meters whose name contains 'http':
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
http.server.requests.active [exception=none, method=GET, outcome=SUCCESS, status=200, uri=UNKNOWN]
-- span-related strings in the OTLP trace payload (1 request(s)):
/hello/ankurJ
/hello/{name}z
http get /hello/{name}0
== management.observations.conventions=open-telemetry
-- meters whose name contains 'http':
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
http.server.requests.active [exception=none, method=GET, outcome=SUCCESS, status=200, uri=UNKNOWN]
-- span-related strings in the OTLP trace payload (1 request(s)):
/hello/ankurJ
/hello/{name}z
http get /hello/{name}0
== management.observations.conventions=opentelemetry
-- meters whose name contains 'http':
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
http.server.requests.active [exception=none, method=GET, outcome=SUCCESS, status=200, uri=UNKNOWN]
-- span-related strings in the OTLP trace payload (1 request(s)):
/hello/ankurJ
/hello/{name}z
http get /hello/{name}0
@@ -0,0 +1,29 @@
# 01-conventions-metrics-and-spans (Spring Boot 4.2.0-RC1, JDK 25)
== management.observations.conventions=(property not set)
-- meters whose name contains 'http':
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
-- span-related strings in the OTLP trace payload (1 request(s)):
/hello/ankurJ
/hello/{name}z
http get /hello/{name}0
== management.observations.conventions=micrometer
-- meters whose name contains 'http':
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
-- span-related strings in the OTLP trace payload (1 request(s)):
/hello/ankurJ
/hello/{name}z
http get /hello/{name}0
== management.observations.conventions=open-telemetry
-- application FAILED to start
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.http.server.observation.ServerRequestObservationConvention' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
== management.observations.conventions=opentelemetry
-- meters whose name contains 'http':
http.server.request.duration [error.type=none, error=none, http.request.method=GET, http.response.status_code=200, http.route=/hello/{name}, outcome=SUCCESS, url.scheme=http]
-- span-related strings in the OTLP trace payload (1 request(s)):
/hello/ankurJ
/hello/{name}J
GET /hello/{name}0
@@ -0,0 +1,7 @@
# 02-shared-otlp-endpoint (Spring Boot 4.1.2, JDK 25)
management.opentelemetry.otlp.endpoint = <sink> (one property, no per-signal URLs)
management.opentelemetry.otlp.headers = Authorization: Bearer shared-token
management.opentelemetry.otlp.compression = gzip
requests that reached the sink:
(none: 4.1.2 ignores the shared properties and exports to its default localhost:4318)
@@ -0,0 +1,8 @@
# 02-shared-otlp-endpoint (Spring Boot 4.2.0-RC1, JDK 25)
management.opentelemetry.otlp.endpoint = <sink> (one property, no per-signal URLs)
management.opentelemetry.otlp.headers = Authorization: Bearer shared-token
management.opentelemetry.otlp.compression = gzip
requests that reached the sink:
POST /v1/metrics Authorization=Bearer shared-token Content-Encoding=gzip
POST /v1/traces Authorization=Bearer shared-token Content-Encoding=gzip
@@ -0,0 +1,9 @@
# 03-signal-specific-overrides-shared (Spring Boot 4.1.2, JDK 25)
shared endpoint = <sink>
tracing.export.otlp.endpoint = <sink>/custom/spans
shared header Authorization = Bearer shared-token
shared header X-Shared = shared-only
tracing.export.otlp.headers.Authorization = Bearer traces-token
requests that reached the sink:
POST /custom/spans Authorization=Bearer traces-token X-Shared=null
@@ -0,0 +1,10 @@
# 03-signal-specific-overrides-shared (Spring Boot 4.2.0-RC1, JDK 25)
shared endpoint = <sink>
tracing.export.otlp.endpoint = <sink>/custom/spans
shared header Authorization = Bearer shared-token
shared header X-Shared = shared-only
tracing.export.otlp.headers.Authorization = Bearer traces-token
requests that reached the sink:
POST /custom/spans Authorization=Bearer traces-token X-Shared=shared-only
POST /v1/metrics Authorization=Bearer shared-token X-Shared=shared-only
@@ -0,0 +1,8 @@
# 04-mdc-keys (Spring Boot 4.1.2, JDK 25)
management.tracing.mdc.trace-id-key = trace_id
management.tracing.mdc.span-id-key = span_id
log pattern correlation part reads both MDC keys
log line: INFO 4192 --- [boot42-otel] [o-auto-1-exec-1] default[<32-hex-trace-id>] custom[] c.a.o.OtelApplication$HelloController : handling hello for ankur
default key traceId populated: true
custom key trace_id populated: false
+8
View File
@@ -0,0 +1,8 @@
# 04-mdc-keys (Spring Boot 4.2.0-RC1, JDK 25)
management.tracing.mdc.trace-id-key = trace_id
management.tracing.mdc.span-id-key = span_id
log pattern correlation part reads both MDC keys
log line: INFO 3810 --- [boot42-otel] [o-auto-1-exec-1] default[] custom[<32-hex-trace-id>] c.a.o.OtelApplication$HelloController : handling hello for ankur
default key traceId populated: false
custom key trace_id populated: true
@@ -0,0 +1,11 @@
# 05-active-long-task-timer (Spring Boot 4.1.2, JDK 25)
== (default)
http.server.requests (TIMER)
http.server.requests.active (LONG_TASK_TIMER)
http.server.requests.active present: true
== management.metrics.observations.include-active-long-task-timer=true
http.server.requests (TIMER)
http.server.requests.active (LONG_TASK_TIMER)
http.server.requests.active present: true
@@ -0,0 +1,10 @@
# 05-active-long-task-timer (Spring Boot 4.2.0-RC1, JDK 25)
== (default)
http.server.requests (TIMER)
http.server.requests.active present: false
== management.metrics.observations.include-active-long-task-timer=true
http.server.requests (TIMER)
http.server.requests.active (LONG_TASK_TIMER)
http.server.requests.active present: true
@@ -0,0 +1,19 @@
# 06-prometheus-names (Spring Boot 4.1.2, JDK 25)
== management.observations.conventions=micrometer
http_server_requests_active_seconds_count
http_server_requests_active_seconds_max
http_server_requests_active_seconds_sum
http_server_requests_seconds_count
http_server_requests_seconds_max
http_server_requests_seconds_sum
sample: http_server_requests_active_seconds_count{exception="none",method="GET",outcome="SUCCESS",status="200",uri="UNKNOWN"}
== management.observations.conventions=opentelemetry
http_server_requests_active_seconds_count
http_server_requests_active_seconds_max
http_server_requests_active_seconds_sum
http_server_requests_seconds_count
http_server_requests_seconds_max
http_server_requests_seconds_sum
sample: http_server_requests_active_seconds_count{exception="none",method="GET",outcome="SUCCESS",status="200",uri="UNKNOWN"}
@@ -0,0 +1,13 @@
# 06-prometheus-names (Spring Boot 4.2.0-RC1, JDK 25)
== management.observations.conventions=micrometer
http_server_requests_seconds_count
http_server_requests_seconds_max
http_server_requests_seconds_sum
sample: http_server_requests_seconds_count{error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/hello/{name}"}
== management.observations.conventions=opentelemetry
http_server_request_duration_seconds_count
http_server_request_duration_seconds_max
http_server_request_duration_seconds_sum
sample: http_server_request_duration_seconds_count{error="none",error_type="none",http_request_method="GET",http_response_status_code="200",http_route="/hello/{name}",outcome="SUCCESS",url_scheme="http"}
@@ -0,0 +1,8 @@
# 07-conventions-spellings (Spring Boot 4.1.2, JDK 25)
open-telemetry -> starts, server meter: [http.server.requests]
open_telemetry -> starts, server meter: [http.server.requests]
OPEN_TELEMETRY -> starts, server meter: [http.server.requests]
opentelemetry -> starts, server meter: [http.server.requests]
openTelemetry -> starts, server meter: [http.server.requests]
micrometer -> starts, server meter: [http.server.requests]
MICROMETER -> starts, server meter: [http.server.requests]
@@ -0,0 +1,8 @@
# 07-conventions-spellings (Spring Boot 4.2.0-RC1, JDK 25)
open-telemetry -> FAILS to start
open_telemetry -> starts, server meter: [http.server.request.duration]
OPEN_TELEMETRY -> starts, server meter: [http.server.request.duration]
opentelemetry -> starts, server meter: [http.server.request.duration]
openTelemetry -> starts, server meter: [http.server.request.duration]
micrometer -> starts, server meter: [http.server.requests]
MICROMETER -> starts, server meter: [http.server.requests]
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>boot42-otel</artifactId>
<version>1.0.0</version>
<name>boot42-otel</name>
<description>Spring Boot 4.2 OpenTelemetry semantic conventions, shared OTLP settings and MDC keys, run against 4.2.0-RC1 and 4.1.2</description>
<properties>
<java.version>25</java.version>
<boot.label>4.1.2</boot.label>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<boot.label>${boot.label}</boot.label>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.2.0-RC1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>boot42-otel</artifactId>
<version>1.0.0</version>
<name>boot42-otel</name>
<description>Spring Boot 4.2 OpenTelemetry semantic conventions, shared OTLP settings and MDC keys, run against 4.2.0-RC1 and 4.1.2</description>
<properties>
<java.version>25</java.version>
<boot.label>4.2.0-RC1</boot.label>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<boot.label>${boot.label}</boot.label>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Regenerates every file in output/ on Spring Boot 4.2.0-RC1 and on the 4.1.2 baseline.
# Needs JDK 25 and Maven 3.9 on PATH. Timing-sensitive lines are not captured on purpose.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q test
mvn -B -q -f pom-41.xml test
echo "done: $(ls output | wc -l) files in output/"
@@ -0,0 +1,30 @@
package com.ankurm.otel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class OtelApplication {
public static void main(String[] args) {
SpringApplication.run(OtelApplication.class, args);
}
@RestController
static class HelloController {
private static final Logger log = LoggerFactory.getLogger(HelloController.class);
// The explicit name avoids the -parameters trap on standalone reactors.
@GetMapping("/hello/{name}")
String hello(@PathVariable("name") String name) {
log.info("handling hello for {}", name);
return "hello " + name;
}
}
}
@@ -0,0 +1,3 @@
spring.application.name=boot42-otel
management.endpoints.web.exposure.include=health,metrics,prometheus
management.tracing.sampling.probability=1.0
@@ -0,0 +1,64 @@
package com.ankurm.otel;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.web.client.RestClient;
/** Starts the demo application on a random port with the given properties. */
final class Apps {
private Apps() {
}
static ConfigurableApplicationContext start(String... properties) {
List<String> props = new ArrayList<>(List.of("server.port=0", "spring.main.banner-mode=off"));
props.addAll(List.of(properties));
return new SpringApplicationBuilder(OtelApplication.class).web(WebApplicationType.SERVLET)
.properties(props.toArray(String[]::new))
.run();
}
static String call(ConfigurableApplicationContext ctx, String path) {
String port = ctx.getEnvironment().getProperty("local.server.port");
return RestClient.create().get().uri("http://localhost:" + port + path).retrieve().body(String.class);
}
/**
* The server observation stops after the response bytes are on the wire, so a client that
* reads the registry immediately can win the race and see nothing. Poll for up to 5 seconds.
*/
static void awaitHttpMeter(MeterRegistry registry) throws InterruptedException {
for (int i = 0; i < 100; i++) {
if (registry.getMeters().stream().anyMatch(m -> m.getId().getName().startsWith("http.server.request")
&& !m.getId().getName().endsWith(".active"))) {
return;
}
Thread.sleep(50);
}
}
/** One line per meter whose name mentions http: name, then sorted tag key=value pairs. */
static List<String> httpMeters(MeterRegistry registry) {
TreeSet<String> out = new TreeSet<>();
for (Meter m : registry.getMeters()) {
String name = m.getId().getName();
if (name.contains("http")) {
List<String> tags = new ArrayList<>();
for (Tag t : m.getId().getTags()) {
tags.add(t.getKey() + "=" + t.getValue());
}
tags.sort(String::compareTo);
out.add(name + " " + tags);
}
}
return new ArrayList<>(out);
}
}
@@ -0,0 +1,57 @@
package com.ankurm.otel;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import io.micrometer.core.instrument.MeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.context.ConfigurableApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The reference documentation says management.observations.conventions=open-telemetry. This
* starts the application once per spelling and records whether it starts and which server HTTP
* meter it ends up with. On 4.2.0-RC1 the documented spelling does not start.
*/
class ConventionSpellingsTest {
@Test
void spellings() throws Exception {
Transcript t = new Transcript("07-conventions-spellings");
Map<String, String> outcome = new LinkedHashMap<>();
for (String value : List.of("open-telemetry", "open_telemetry", "OPEN_TELEMETRY", "opentelemetry",
"openTelemetry", "micrometer", "MICROMETER")) {
String result;
try {
ConfigurableApplicationContext ctx = Apps.start("management.observations.conventions=" + value,
"management.otlp.metrics.export.enabled=false");
Apps.call(ctx, "/hello/x");
MeterRegistry registry = ctx.getBean(MeterRegistry.class);
Apps.awaitHttpMeter(registry);
result = "starts, server meter: " + registry.getMeters().stream()
.map(m -> m.getId().getName())
.filter(n -> n.startsWith("http.server.request") && !n.endsWith(".active"))
.distinct()
.sorted()
.toList();
ctx.close();
}
catch (Exception ex) {
result = "FAILS to start";
}
outcome.put(value, result);
t.line(String.format("%-16s -> %s", value, result));
}
t.write();
if (Transcript.LABEL.equals("4.2.0-RC1")) {
assertThat(outcome.get("open-telemetry")).isEqualTo("FAILS to start");
assertThat(outcome.get("opentelemetry")).contains("http.server.request.duration");
assertThat(outcome.get("micrometer")).contains("http.server.requests");
}
else if (!Transcript.is42()) {
assertThat(outcome.values()).allMatch(v -> v.contains("http.server.requests"));
}
}
}
@@ -0,0 +1,91 @@
package com.ankurm.otel;
import java.util.List;
import io.micrometer.core.instrument.MeterRegistry;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import org.springframework.context.ConfigurableApplicationContext;
/**
* One request against /hello/{name}, measured with each value of
* management.observations.conventions (and with the property absent). Writes the HTTP meter names
* and tags, and the span-related strings found in the OTLP trace payload. A start-up failure is
* recorded, not hidden.
*/
class ConventionsTest {
@Test
void metricsAndSpans() throws Exception {
Transcript t = new Transcript("01-conventions-metrics-and-spans");
java.util.Map<String, List<String>> meters = new java.util.LinkedHashMap<>();
java.util.Map<String, String> failures = new java.util.LinkedHashMap<>();
for (String mode : List.of("(property not set)", "micrometer", "open-telemetry", "opentelemetry")) {
try (OtlpSink sink = new OtlpSink()) {
t.line("");
t.line("== management.observations.conventions=" + mode);
ConfigurableApplicationContext ctx = null;
try {
String[] props = mode.startsWith("(")
? new String[] { "management.otlp.metrics.export.enabled=false",
"management.opentelemetry.tracing.export.otlp.endpoint=" + sink.baseUrl() + "/v1/traces" }
: new String[] { "management.observations.conventions=" + mode,
"management.otlp.metrics.export.enabled=false",
"management.opentelemetry.tracing.export.otlp.endpoint=" + sink.baseUrl() + "/v1/traces" };
ctx = Apps.start(props);
}
catch (Exception ex) {
Throwable root = ex;
while (root.getCause() != null) {
root = root.getCause();
}
t.line("-- application FAILED to start");
t.line(" " + root.getClass().getSimpleName() + ": " + firstLine(root.getMessage()));
failures.put(mode, root.getClass().getSimpleName());
continue;
}
Apps.call(ctx, "/hello/ankur");
MeterRegistry registry = ctx.getBean(MeterRegistry.class);
Apps.awaitHttpMeter(registry);
t.line("-- meters whose name contains 'http':");
List<String> found = Apps.httpMeters(registry);
meters.put(mode, found);
found.forEach(l -> t.line(" " + l));
ctx.close();
Thread.sleep(500);
t.line("-- span-related strings in the OTLP trace payload (" + sink.hits().size() + " request(s)):");
sink.hits().stream()
.flatMap(h -> h.strings().stream())
.filter(s -> s.contains("hello") || s.contains("GET") || s.contains("get "))
.distinct()
.forEach(s -> t.line(" " + s));
}
}
t.write();
if (Transcript.is42()) {
assertThat(meters.get("(property not set)")).anyMatch(l -> l.startsWith("http.server.requests "));
assertThat(meters.get("micrometer")).anyMatch(l -> l.startsWith("http.server.requests "));
assertThat(meters.get("opentelemetry")).anyMatch(l -> l.startsWith("http.server.request.duration "));
assertThat(meters.get("opentelemetry")).noneMatch(l -> l.startsWith("http.server.requests "));
}
if (Transcript.LABEL.equals("4.2.0-RC1")) {
// Pinned on purpose: when this stops failing, the RC1 section of the article is stale.
assertThat(failures).containsKey("open-telemetry");
assertThat(failures).doesNotContainKey("opentelemetry");
}
else if (!Transcript.is42()) {
assertThat(failures).isEmpty();
assertThat(meters.get("opentelemetry")).anyMatch(l -> l.startsWith("http.server.requests "));
}
}
private static String firstLine(String s) {
if (s == null) {
return "";
}
int nl = s.indexOf('\n');
String line = nl < 0 ? s : s.substring(0, nl);
return line.length() > 260 ? line.substring(0, 260) + "..." : line;
}
}
@@ -0,0 +1,88 @@
package com.ankurm.otel;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.context.ConfigurableApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Two smaller 4.2 changes that break dashboards and log queries rather than builds: the MDC keys
* that carry the trace and span ids, and the active-observation LongTaskTimer that is no longer
* created by default.
*/
@ExtendWith(OutputCaptureExtension.class)
class MdcAndLongTaskTimerTest {
@Test
void mdcKeys(CapturedOutput output) {
Transcript t = new Transcript("04-mdc-keys");
// The pattern reads both the default key names and the custom ones, so one log line shows
// which key the tracing bridge really populated.
ConfigurableApplicationContext ctx = Apps.start("management.otlp.metrics.export.enabled=false",
"management.tracing.mdc.trace-id-key=trace_id", "management.tracing.mdc.span-id-key=span_id",
"logging.pattern.correlation=default[%X{traceId:-}] custom[%X{trace_id:-}] ");
Apps.call(ctx, "/hello/ankur");
ctx.close();
String line = output.getAll().lines().filter(l -> l.contains("handling hello for ankur")).findFirst().orElseThrow();
String correlation = line.substring(line.indexOf("default["), line.indexOf("] : ") > 0 ? line.indexOf("] :") : line.length());
t.line("management.tracing.mdc.trace-id-key = trace_id");
t.line("management.tracing.mdc.span-id-key = span_id");
t.line("log pattern correlation part reads both MDC keys");
t.line("");
t.line("log line: " + line.replaceAll("^\\S+\\s+", "").replaceAll("(?<=\\[)[0-9a-f]{32}(?=\\])", "<32-hex-trace-id>"));
boolean defaultKeyFilled = line.matches(".*default\\[[0-9a-f]{32}\\].*");
boolean customKeyFilled = line.matches(".*custom\\[[0-9a-f]{32}\\].*");
t.line("default key traceId populated: " + defaultKeyFilled);
t.line("custom key trace_id populated: " + customKeyFilled);
if (Transcript.is42()) {
assertThat(customKeyFilled).isTrue();
assertThat(defaultKeyFilled).isFalse();
}
else {
assertThat(customKeyFilled).isFalse();
assertThat(defaultKeyFilled).isTrue();
}
t.write();
}
@Test
void activeLongTaskTimer() throws Exception {
Transcript t = new Transcript("05-active-long-task-timer");
for (String setting : List.of("(default)", "management.metrics.observations.include-active-long-task-timer=true")) {
ConfigurableApplicationContext ctx = setting.startsWith("(")
? Apps.start("management.otlp.metrics.export.enabled=false")
: Apps.start("management.otlp.metrics.export.enabled=false", setting);
Apps.call(ctx, "/hello/ankur");
MeterRegistry registry = ctx.getBean(MeterRegistry.class);
Apps.awaitHttpMeter(registry);
TreeSet<String> names = new TreeSet<>();
for (Meter m : registry.getMeters()) {
if (m.getId().getName().startsWith("http.server")) {
names.add(m.getId().getName() + " (" + m.getId().getType() + ")");
}
}
t.line("");
t.line("== " + setting);
names.forEach(n -> t.line(" " + n));
boolean active = names.stream().anyMatch(n -> n.startsWith("http.server.requests.active"));
t.line(" http.server.requests.active present: " + active);
if (Transcript.is42()) {
assertThat(active).isEqualTo(!setting.startsWith("("));
}
else if (setting.startsWith("(")) {
assertThat(active).isTrue();
}
ctx.close();
}
t.write();
}
}
@@ -0,0 +1,91 @@
package com.ankurm.otel;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.zip.GZIPInputStream;
import com.sun.net.httpserver.HttpServer;
/**
* A tiny OTLP/HTTP receiver: it accepts any POST, answers 200 with an empty protobuf body and
* remembers the path, a few headers and the (decompressed) payload bytes. Enough to see where an
* exporter really sends data, with which headers, and which strings are inside the payload.
*/
final class OtlpSink implements AutoCloseable {
record Hit(String path, String authorization, String xShared, String contentEncoding, String contentType, byte[] body) {
/** Printable ASCII runs of at least 6 characters, a poor man's protobuf string reader. */
List<String> strings() {
TreeSet<String> out = new TreeSet<>();
StringBuilder sb = new StringBuilder();
for (byte b : body) {
if (b >= 32 && b < 127) {
sb.append((char) b);
}
else {
flush(sb, out);
}
}
flush(sb, out);
return new ArrayList<>(out);
}
private static void flush(StringBuilder sb, TreeSet<String> out) {
if (sb.length() >= 6) {
out.add(sb.toString());
}
sb.setLength(0);
}
}
private final HttpServer server;
private final List<Hit> hits = new CopyOnWriteArrayList<>();
OtlpSink() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/", exchange -> {
byte[] raw = exchange.getRequestBody().readAllBytes();
String enc = exchange.getRequestHeaders().getFirst("Content-Encoding");
byte[] body = raw;
if ("gzip".equalsIgnoreCase(enc)) {
try (InputStream in = new GZIPInputStream(new java.io.ByteArrayInputStream(raw))) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
in.transferTo(bos);
body = bos.toByteArray();
}
}
hits.add(new Hit(exchange.getRequestURI().getPath(), exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Shared"), enc, exchange.getRequestHeaders().getFirst("Content-Type"), body));
exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf");
exchange.sendResponseHeaders(200, -1);
exchange.close();
});
server.start();
}
int port() {
return server.getAddress().getPort();
}
String baseUrl() {
return "http://localhost:" + port();
}
List<Hit> hits() {
return List.copyOf(hits);
}
@Override
public void close() {
server.stop(0);
hits.clear();
}
}
@@ -0,0 +1,57 @@
package com.ankurm.otel;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What a dashboard query sees. Scrapes /actuator/prometheus after one request and lists the
* http_server* families and one labelled sample, for the default conventions and for the
* open-telemetry conventions (spelled the way RC1 accepts it).
*/
class PrometheusNamesTest {
@Test
void promNames() throws Exception {
Transcript t = new Transcript("06-prometheus-names");
List<String> modes = List.of("micrometer", "opentelemetry");
java.util.Map<String, List<String>> families = new java.util.LinkedHashMap<>();
for (String mode : modes) {
ConfigurableApplicationContext ctx = Apps.start("management.observations.conventions=" + mode,
"management.otlp.metrics.export.enabled=false");
Apps.call(ctx, "/hello/ankur");
io.micrometer.core.instrument.MeterRegistry registry = ctx.getBean(io.micrometer.core.instrument.MeterRegistry.class);
Apps.awaitHttpMeter(registry);
String port = ctx.getEnvironment().getProperty("local.server.port");
String body = RestClient.create().get().uri("http://localhost:" + port + "/actuator/prometheus")
.retrieve().body(String.class);
TreeSet<String> names = new TreeSet<>();
String sample = "";
for (String line : body.split("\n")) {
if (line.startsWith("http_server")) {
names.add(line.substring(0, line.indexOf('{') > 0 ? line.indexOf('{') : line.indexOf(' ')));
if (line.contains("_count{") && sample.isEmpty()) {
sample = line.substring(0, line.lastIndexOf(' '));
}
}
}
t.line("");
t.line("== management.observations.conventions=" + mode);
names.forEach(n -> t.line(" " + n));
t.line(" sample: " + sample);
families.put(mode, new ArrayList<>(names));
ctx.close();
}
t.write();
if (Transcript.is42()) {
assertThat(families.get("micrometer")).contains("http_server_requests_seconds_count");
assertThat(families.get("opentelemetry")).contains("http_server_request_duration_seconds_count");
}
}
}
@@ -0,0 +1,92 @@
package com.ankurm.otel;
import java.util.List;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.context.ConfigurableApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 4.2.0-M2 added management.opentelemetry.otlp.{endpoint,headers,compression}, shared by traces,
* logs and metrics. A local OTLP receiver shows which paths the exporters really call, with which
* headers, and what a signal-specific property does to the shared one.
*/
class SharedOtlpTest {
@Test
void sharedEndpointHeadersAndCompression() throws Exception {
Transcript t = new Transcript("02-shared-otlp-endpoint");
try (OtlpSink sink = new OtlpSink()) {
ConfigurableApplicationContext ctx = Apps.start(
"management.opentelemetry.otlp.endpoint=" + sink.baseUrl(),
"management.opentelemetry.otlp.headers.Authorization=Bearer shared-token",
"management.opentelemetry.otlp.compression=gzip",
"management.otlp.metrics.export.step=1s");
Apps.call(ctx, "/hello/ankur");
Thread.sleep(2500);
ctx.close();
Thread.sleep(500);
t.line("management.opentelemetry.otlp.endpoint = <sink> (one property, no per-signal URLs)");
t.line("management.opentelemetry.otlp.headers = Authorization: Bearer shared-token");
t.line("management.opentelemetry.otlp.compression = gzip");
t.line("");
TreeSet<String> seen = new TreeSet<>();
for (OtlpSink.Hit h : sink.hits()) {
seen.add(String.format("POST %-12s Authorization=%s Content-Encoding=%s", h.path(), h.authorization(),
h.contentEncoding()));
}
t.line("requests that reached the sink:");
seen.forEach(l -> t.line(" " + l));
if (Transcript.is42()) {
assertThat(seen).anyMatch(l -> l.contains("/v1/traces") && l.contains("Bearer shared-token") && l.contains("gzip"));
assertThat(seen).anyMatch(l -> l.contains("/v1/metrics") && l.contains("Bearer shared-token") && l.contains("gzip"));
}
else {
assertThat(sink.hits()).isEmpty();
t.line(" (none: 4.1.2 ignores the shared properties and exports to its default localhost:4318)");
}
}
t.write();
}
@Test
void signalSpecificPropertyWins() throws Exception {
Transcript t = new Transcript("03-signal-specific-overrides-shared");
try (OtlpSink sink = new OtlpSink()) {
ConfigurableApplicationContext ctx = Apps.start(
"management.opentelemetry.otlp.endpoint=" + sink.baseUrl(),
"management.opentelemetry.otlp.headers.Authorization=Bearer shared-token",
"management.opentelemetry.otlp.headers.X-Shared=shared-only",
"management.opentelemetry.tracing.export.otlp.endpoint=" + sink.baseUrl() + "/custom/spans",
"management.opentelemetry.tracing.export.otlp.headers.Authorization=Bearer traces-token",
"management.otlp.metrics.export.step=1s");
Apps.call(ctx, "/hello/ankur");
Thread.sleep(2500);
ctx.close();
Thread.sleep(500);
t.line("shared endpoint = <sink>");
t.line("tracing.export.otlp.endpoint = <sink>/custom/spans");
t.line("shared header Authorization = Bearer shared-token");
t.line("shared header X-Shared = shared-only");
t.line("tracing.export.otlp.headers.Authorization = Bearer traces-token");
t.line("");
List<String> lines = sink.hits().stream()
.map(h -> String.format("POST %-14s Authorization=%s X-Shared=%s", h.path(), h.authorization(), h.xShared()))
.distinct()
.sorted()
.toList();
t.line("requests that reached the sink:");
lines.forEach(l -> t.line(" " + l));
if (Transcript.is42()) {
assertThat(lines).anyMatch(l -> l.contains("/custom/spans") && l.contains("Bearer traces-token")
&& l.contains("X-Shared=shared-only"));
assertThat(lines).anyMatch(l -> l.contains("/v1/metrics") && l.contains("Bearer shared-token")
&& l.contains("X-Shared=shared-only"));
assertThat(lines).noneMatch(l -> l.contains("/custom/spans/v1/traces"));
}
}
t.write();
}
}
@@ -0,0 +1,41 @@
package com.ankurm.otel;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Collects lines and writes output/NN-name[-on-4.1.2].txt. Tests assert what they write. */
final class Transcript {
static final String LABEL = System.getProperty("boot.label", "4.2.0-RC1");
static boolean is42() {
return LABEL.startsWith("4.2");
}
private final List<String> lines = new ArrayList<>();
private final Path file;
Transcript(String name) {
String suffix = is42() ? "" : "-on-" + LABEL;
this.file = Path.of("output", name + suffix + ".txt");
line("# " + name + " (Spring Boot " + LABEL + ", JDK " + Runtime.version().feature() + ")");
}
Transcript line(String s) {
lines.add(s);
return this;
}
void write() {
try {
Files.createDirectories(file.getParent());
Files.write(file, lines);
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
}