From 8252cba4c5138956381d5bbb8978d4588a68d5a2 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Thu, 22 Oct 2026 18:34:34 +0000 Subject: [PATCH] Add boot42-otel module: Spring Boot 4.2.0-RC1 OpenTelemetry conventions, shared OTLP settings and MDC keys vs 4.1.2 --- README.md | 1 + boot42-otel/README.md | 20 ++++ ...conventions-metrics-and-spans-on-4.1.2.txt | 37 ++++++++ .../01-conventions-metrics-and-spans.txt | 29 ++++++ .../02-shared-otlp-endpoint-on-4.1.2.txt | 7 ++ .../output/02-shared-otlp-endpoint.txt | 8 ++ ...nal-specific-overrides-shared-on-4.1.2.txt | 9 ++ .../03-signal-specific-overrides-shared.txt | 10 ++ boot42-otel/output/04-mdc-keys-on-4.1.2.txt | 8 ++ boot42-otel/output/04-mdc-keys.txt | 8 ++ .../05-active-long-task-timer-on-4.1.2.txt | 11 +++ .../output/05-active-long-task-timer.txt | 10 ++ .../output/06-prometheus-names-on-4.1.2.txt | 19 ++++ boot42-otel/output/06-prometheus-names.txt | 13 +++ .../07-conventions-spellings-on-4.1.2.txt | 8 ++ .../output/07-conventions-spellings.txt | 8 ++ boot42-otel/pom-41.xml | 65 +++++++++++++ boot42-otel/pom.xml | 65 +++++++++++++ boot42-otel/scripts/run-all.sh | 8 ++ .../java/com/ankurm/otel/OtelApplication.java | 30 ++++++ .../src/main/resources/application.properties | 3 + .../src/test/java/com/ankurm/otel/Apps.java | 64 +++++++++++++ .../ankurm/otel/ConventionSpellingsTest.java | 57 ++++++++++++ .../java/com/ankurm/otel/ConventionsTest.java | 91 ++++++++++++++++++ .../ankurm/otel/MdcAndLongTaskTimerTest.java | 88 ++++++++++++++++++ .../test/java/com/ankurm/otel/OtlpSink.java | 91 ++++++++++++++++++ .../com/ankurm/otel/PrometheusNamesTest.java | 57 ++++++++++++ .../java/com/ankurm/otel/SharedOtlpTest.java | 92 +++++++++++++++++++ .../test/java/com/ankurm/otel/Transcript.java | 41 +++++++++ 29 files changed, 958 insertions(+) create mode 100644 boot42-otel/README.md create mode 100644 boot42-otel/output/01-conventions-metrics-and-spans-on-4.1.2.txt create mode 100644 boot42-otel/output/01-conventions-metrics-and-spans.txt create mode 100644 boot42-otel/output/02-shared-otlp-endpoint-on-4.1.2.txt create mode 100644 boot42-otel/output/02-shared-otlp-endpoint.txt create mode 100644 boot42-otel/output/03-signal-specific-overrides-shared-on-4.1.2.txt create mode 100644 boot42-otel/output/03-signal-specific-overrides-shared.txt create mode 100644 boot42-otel/output/04-mdc-keys-on-4.1.2.txt create mode 100644 boot42-otel/output/04-mdc-keys.txt create mode 100644 boot42-otel/output/05-active-long-task-timer-on-4.1.2.txt create mode 100644 boot42-otel/output/05-active-long-task-timer.txt create mode 100644 boot42-otel/output/06-prometheus-names-on-4.1.2.txt create mode 100644 boot42-otel/output/06-prometheus-names.txt create mode 100644 boot42-otel/output/07-conventions-spellings-on-4.1.2.txt create mode 100644 boot42-otel/output/07-conventions-spellings.txt create mode 100644 boot42-otel/pom-41.xml create mode 100644 boot42-otel/pom.xml create mode 100755 boot42-otel/scripts/run-all.sh create mode 100644 boot42-otel/src/main/java/com/ankurm/otel/OtelApplication.java create mode 100644 boot42-otel/src/main/resources/application.properties create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/Apps.java create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/ConventionSpellingsTest.java create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/ConventionsTest.java create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/MdcAndLongTaskTimerTest.java create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/OtlpSink.java create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/PrometheusNamesTest.java create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/SharedOtlpTest.java create mode 100644 boot42-otel/src/test/java/com/ankurm/otel/Transcript.java diff --git a/README.md b/README.md index 80d7b64..b9504b8 100644 --- a/README.md +++ b/README.md @@ -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 | | [`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-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 collapsible sections inside the articles themselves. Where their captured output sits is not diff --git a/boot42-otel/README.md b/boot42-otel/README.md new file mode 100644 index 0000000..455d2fc --- /dev/null +++ b/boot42-otel/README.md @@ -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. diff --git a/boot42-otel/output/01-conventions-metrics-and-spans-on-4.1.2.txt b/boot42-otel/output/01-conventions-metrics-and-spans-on-4.1.2.txt new file mode 100644 index 0000000..1d356a2 --- /dev/null +++ b/boot42-otel/output/01-conventions-metrics-and-spans-on-4.1.2.txt @@ -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 diff --git a/boot42-otel/output/01-conventions-metrics-and-spans.txt b/boot42-otel/output/01-conventions-metrics-and-spans.txt new file mode 100644 index 0000000..96fe553 --- /dev/null +++ b/boot42-otel/output/01-conventions-metrics-and-spans.txt @@ -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 diff --git a/boot42-otel/output/02-shared-otlp-endpoint-on-4.1.2.txt b/boot42-otel/output/02-shared-otlp-endpoint-on-4.1.2.txt new file mode 100644 index 0000000..a7b980b --- /dev/null +++ b/boot42-otel/output/02-shared-otlp-endpoint-on-4.1.2.txt @@ -0,0 +1,7 @@ +# 02-shared-otlp-endpoint (Spring Boot 4.1.2, JDK 25) +management.opentelemetry.otlp.endpoint = (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) diff --git a/boot42-otel/output/02-shared-otlp-endpoint.txt b/boot42-otel/output/02-shared-otlp-endpoint.txt new file mode 100644 index 0000000..569c5c9 --- /dev/null +++ b/boot42-otel/output/02-shared-otlp-endpoint.txt @@ -0,0 +1,8 @@ +# 02-shared-otlp-endpoint (Spring Boot 4.2.0-RC1, JDK 25) +management.opentelemetry.otlp.endpoint = (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 diff --git a/boot42-otel/output/03-signal-specific-overrides-shared-on-4.1.2.txt b/boot42-otel/output/03-signal-specific-overrides-shared-on-4.1.2.txt new file mode 100644 index 0000000..c1c81f8 --- /dev/null +++ b/boot42-otel/output/03-signal-specific-overrides-shared-on-4.1.2.txt @@ -0,0 +1,9 @@ +# 03-signal-specific-overrides-shared (Spring Boot 4.1.2, JDK 25) +shared endpoint = +tracing.export.otlp.endpoint = /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 diff --git a/boot42-otel/output/03-signal-specific-overrides-shared.txt b/boot42-otel/output/03-signal-specific-overrides-shared.txt new file mode 100644 index 0000000..1f44142 --- /dev/null +++ b/boot42-otel/output/03-signal-specific-overrides-shared.txt @@ -0,0 +1,10 @@ +# 03-signal-specific-overrides-shared (Spring Boot 4.2.0-RC1, JDK 25) +shared endpoint = +tracing.export.otlp.endpoint = /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 diff --git a/boot42-otel/output/04-mdc-keys-on-4.1.2.txt b/boot42-otel/output/04-mdc-keys-on-4.1.2.txt new file mode 100644 index 0000000..718c9ba --- /dev/null +++ b/boot42-otel/output/04-mdc-keys-on-4.1.2.txt @@ -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 diff --git a/boot42-otel/output/04-mdc-keys.txt b/boot42-otel/output/04-mdc-keys.txt new file mode 100644 index 0000000..765c313 --- /dev/null +++ b/boot42-otel/output/04-mdc-keys.txt @@ -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 diff --git a/boot42-otel/output/05-active-long-task-timer-on-4.1.2.txt b/boot42-otel/output/05-active-long-task-timer-on-4.1.2.txt new file mode 100644 index 0000000..41d0953 --- /dev/null +++ b/boot42-otel/output/05-active-long-task-timer-on-4.1.2.txt @@ -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 diff --git a/boot42-otel/output/05-active-long-task-timer.txt b/boot42-otel/output/05-active-long-task-timer.txt new file mode 100644 index 0000000..a5ed500 --- /dev/null +++ b/boot42-otel/output/05-active-long-task-timer.txt @@ -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 diff --git a/boot42-otel/output/06-prometheus-names-on-4.1.2.txt b/boot42-otel/output/06-prometheus-names-on-4.1.2.txt new file mode 100644 index 0000000..e63ebd9 --- /dev/null +++ b/boot42-otel/output/06-prometheus-names-on-4.1.2.txt @@ -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"} diff --git a/boot42-otel/output/06-prometheus-names.txt b/boot42-otel/output/06-prometheus-names.txt new file mode 100644 index 0000000..9fc0db0 --- /dev/null +++ b/boot42-otel/output/06-prometheus-names.txt @@ -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"} diff --git a/boot42-otel/output/07-conventions-spellings-on-4.1.2.txt b/boot42-otel/output/07-conventions-spellings-on-4.1.2.txt new file mode 100644 index 0000000..302c0dc --- /dev/null +++ b/boot42-otel/output/07-conventions-spellings-on-4.1.2.txt @@ -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] diff --git a/boot42-otel/output/07-conventions-spellings.txt b/boot42-otel/output/07-conventions-spellings.txt new file mode 100644 index 0000000..2c6e85b --- /dev/null +++ b/boot42-otel/output/07-conventions-spellings.txt @@ -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] diff --git a/boot42-otel/pom-41.xml b/boot42-otel/pom-41.xml new file mode 100644 index 0000000..6793d1f --- /dev/null +++ b/boot42-otel/pom-41.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm + boot42-otel + 1.0.0 + boot42-otel + Spring Boot 4.2 OpenTelemetry semantic conventions, shared OTLP settings and MDC keys, run against 4.2.0-RC1 and 4.1.2 + + + 25 + 4.1.2 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-opentelemetry + + + io.micrometer + micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + maven-surefire-plugin + + + ${boot.label} + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/boot42-otel/pom.xml b/boot42-otel/pom.xml new file mode 100644 index 0000000..4b60eee --- /dev/null +++ b/boot42-otel/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.2.0-RC1 + + + + com.ankurm + boot42-otel + 1.0.0 + boot42-otel + Spring Boot 4.2 OpenTelemetry semantic conventions, shared OTLP settings and MDC keys, run against 4.2.0-RC1 and 4.1.2 + + + 25 + 4.2.0-RC1 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-opentelemetry + + + io.micrometer + micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + maven-surefire-plugin + + + ${boot.label} + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/boot42-otel/scripts/run-all.sh b/boot42-otel/scripts/run-all.sh new file mode 100755 index 0000000..8b613e1 --- /dev/null +++ b/boot42-otel/scripts/run-all.sh @@ -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/" diff --git a/boot42-otel/src/main/java/com/ankurm/otel/OtelApplication.java b/boot42-otel/src/main/java/com/ankurm/otel/OtelApplication.java new file mode 100644 index 0000000..2e1b3bc --- /dev/null +++ b/boot42-otel/src/main/java/com/ankurm/otel/OtelApplication.java @@ -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; + } + } +} diff --git a/boot42-otel/src/main/resources/application.properties b/boot42-otel/src/main/resources/application.properties new file mode 100644 index 0000000..4d7499b --- /dev/null +++ b/boot42-otel/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.application.name=boot42-otel +management.endpoints.web.exposure.include=health,metrics,prometheus +management.tracing.sampling.probability=1.0 diff --git a/boot42-otel/src/test/java/com/ankurm/otel/Apps.java b/boot42-otel/src/test/java/com/ankurm/otel/Apps.java new file mode 100644 index 0000000..7d43c60 --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/Apps.java @@ -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 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 httpMeters(MeterRegistry registry) { + TreeSet out = new TreeSet<>(); + for (Meter m : registry.getMeters()) { + String name = m.getId().getName(); + if (name.contains("http")) { + List 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); + } +} diff --git a/boot42-otel/src/test/java/com/ankurm/otel/ConventionSpellingsTest.java b/boot42-otel/src/test/java/com/ankurm/otel/ConventionSpellingsTest.java new file mode 100644 index 0000000..2a15497 --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/ConventionSpellingsTest.java @@ -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 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")); + } + } +} diff --git a/boot42-otel/src/test/java/com/ankurm/otel/ConventionsTest.java b/boot42-otel/src/test/java/com/ankurm/otel/ConventionsTest.java new file mode 100644 index 0000000..56f4c1b --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/ConventionsTest.java @@ -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> meters = new java.util.LinkedHashMap<>(); + java.util.Map 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 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; + } +} diff --git a/boot42-otel/src/test/java/com/ankurm/otel/MdcAndLongTaskTimerTest.java b/boot42-otel/src/test/java/com/ankurm/otel/MdcAndLongTaskTimerTest.java new file mode 100644 index 0000000..f44bb4d --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/MdcAndLongTaskTimerTest.java @@ -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 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(); + } +} diff --git a/boot42-otel/src/test/java/com/ankurm/otel/OtlpSink.java b/boot42-otel/src/test/java/com/ankurm/otel/OtlpSink.java new file mode 100644 index 0000000..637f6f4 --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/OtlpSink.java @@ -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 strings() { + TreeSet 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 out) { + if (sb.length() >= 6) { + out.add(sb.toString()); + } + sb.setLength(0); + } + } + + private final HttpServer server; + private final List 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 hits() { + return List.copyOf(hits); + } + + @Override + public void close() { + server.stop(0); + hits.clear(); + } +} diff --git a/boot42-otel/src/test/java/com/ankurm/otel/PrometheusNamesTest.java b/boot42-otel/src/test/java/com/ankurm/otel/PrometheusNamesTest.java new file mode 100644 index 0000000..ab0689b --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/PrometheusNamesTest.java @@ -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 modes = List.of("micrometer", "opentelemetry"); + java.util.Map> 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 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"); + } + } +} diff --git a/boot42-otel/src/test/java/com/ankurm/otel/SharedOtlpTest.java b/boot42-otel/src/test/java/com/ankurm/otel/SharedOtlpTest.java new file mode 100644 index 0000000..0d21546 --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/SharedOtlpTest.java @@ -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 = (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 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 = "); + t.line("tracing.export.otlp.endpoint = /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 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(); + } +} diff --git a/boot42-otel/src/test/java/com/ankurm/otel/Transcript.java b/boot42-otel/src/test/java/com/ankurm/otel/Transcript.java new file mode 100644 index 0000000..981b177 --- /dev/null +++ b/boot42-otel/src/test/java/com/ankurm/otel/Transcript.java @@ -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 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); + } + } +}