Skip to content

Commit 7b8ecc1

Browse files
committed
feat(otel): carry W3C traceparent via Spring AMQP MessageProperties (ADR-0028)
Implements the babelqueue-core 1.5.0 header seam so traceparent rides the broker beside the frozen envelope (GR-1), merge-not-clobber + (Redis) bare-value back- compat; no header falls back to the v0.1 trace_id mapping. Core dep bumped to 1.5.0; package bumped to 1.1.0.
1 parent 703512b commit 7b8ecc1

6 files changed

Lines changed: 332 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
### Added
13+
- **OpenTelemetry `traceparent` transport wiring (ADR-0028, v0.2).**
14+
`BabelQueuePublisher` gains `publishWithHeaders(Envelope, Map<String,String>)` — the
15+
produce-side seam the optional core `com.babelqueue.otel.HeaderSender` wires to: it sends
16+
through a `MessagePostProcessor` that writes out-of-band headers (e.g. a W3C
17+
`traceparent`) onto the AMQP `MessageProperties` headers **beside** the contract `x-*`
18+
headers (`SpringHeaders.apply`: a contract header always wins a key collision), never
19+
inside the frozen envelope (GR-1). New `SpringHeaders.of(Message)` /
20+
`of(MessageProperties)` surface a delivered message's headers as a `Map<String,String>`,
21+
the consume-side seam a `@RabbitListener` wires to `Tracing.wrapHandler(tracer, handler,
22+
Supplier)`, so a carried `traceparent` makes the consumer span a true child of the
23+
producer span. A header-less publish is equivalent to before; `trace_id` is preserved
24+
(GR-4); `schema_version` stays **1**. No new runtime dependency — the header seam is a
25+
plain `Map<String,String>`; OpenTelemetry is needed only by callers who opt in.
26+
27+
### Changed
28+
- Require `com.babelqueue:babelqueue-core 1.5.0` (the out-of-band header-carrier seam).
29+
1230
## [1.0.0] - 2026-06-07
1331

1432
**1.0.0 — the public API is now SemVer-stable**: breaking changes require a MAJOR,

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,30 @@ Non-conformant messages (missing URN, unsupported `meta.schema_version`, blank
108108
`trace_id`, missing `data`) raise a `MessageConversionException`, so Spring rejects
109109
them — route them to a dead-letter exchange the usual Spring AMQP way.
110110

111+
## Trace propagation (OpenTelemetry `traceparent`, ADR-0028)
112+
113+
The optional core `com.babelqueue.otel` module can carry a W3C `traceparent` so a
114+
consumer span becomes a true child of the producer span — propagated **out of band** on
115+
the AMQP `MessageProperties` headers, beside the contract `x-*` headers (a contract header
116+
always wins a key collision), never inside the frozen envelope (GR-1).
117+
118+
```java
119+
// produce: HeaderSender -> BabelQueuePublisher.publishWithHeaders
120+
Tracing.publish(tracer, "urn:babel:orders:created", Map.of("order_id", 1042L), "orders",
121+
(envelope, headers) -> babelQueue.publishWithHeaders(envelope, headers));
122+
123+
// consume: take the raw Message too and surface its headers for wrapHandler's Supplier
124+
@RabbitListener(queues = "orders")
125+
void onMessage(Envelope envelope, Message message) throws Exception {
126+
Tracing.wrapHandler(tracer, h, () -> SpringHeaders.of(message)).handle(envelope);
127+
}
128+
```
129+
130+
A header-less `publish(...)` is unchanged; with no `traceparent` the consumer falls back
131+
to the v0.1 `trace_id`-derived parent. Requires `babelqueue-core` ≥ 1.5.0. No
132+
OpenTelemetry dependency is needed unless you opt in — the seam is a plain
133+
`Map<String,String>`.
134+
111135
## Configuration
112136

113137
```yaml

pom.xml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.babelqueue</groupId>
88
<artifactId>babelqueue-spring</artifactId>
9-
<version>1.0.0</version>
9+
<version>1.1.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>BabelQueue Spring</name>
@@ -50,8 +50,9 @@
5050
<maven.compiler.release>17</maven.compiler.release>
5151
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
5252
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
53-
<babelqueue-core.version>1.0.0</babelqueue-core.version>
53+
<babelqueue-core.version>1.5.0</babelqueue-core.version>
5454
<spring-boot.version>3.3.5</spring-boot.version>
55+
<opentelemetry.version>1.45.0</opentelemetry.version>
5556
</properties>
5657

5758
<dependencyManagement>
@@ -63,6 +64,13 @@
6364
<type>pom</type>
6465
<scope>import</scope>
6566
</dependency>
67+
<dependency>
68+
<groupId>io.opentelemetry</groupId>
69+
<artifactId>opentelemetry-bom</artifactId>
70+
<version>${opentelemetry.version}</version>
71+
<type>pom</type>
72+
<scope>import</scope>
73+
</dependency>
6674
</dependencies>
6775
</dependencyManagement>
6876

@@ -97,6 +105,23 @@
97105
<artifactId>spring-boot-starter-test</artifactId>
98106
<scope>test</scope>
99107
</dependency>
108+
<!--
109+
The core's OTel module is optional (not transitive), so the end-to-end
110+
traceparent cross-hop test (Tracing.publish HeaderSender -> wrapHandler Supplier
111+
over Spring AMQP MessageProperties headers) pulls the SDK + in-memory exporter in
112+
test scope. Runtime use of this adapter needs no OTel dependency at all — the
113+
header seam is a plain Map<String,String>.
114+
-->
115+
<dependency>
116+
<groupId>io.opentelemetry</groupId>
117+
<artifactId>opentelemetry-sdk</artifactId>
118+
<scope>test</scope>
119+
</dependency>
120+
<dependency>
121+
<groupId>io.opentelemetry</groupId>
122+
<artifactId>opentelemetry-sdk-testing</artifactId>
123+
<scope>test</scope>
124+
</dependency>
100125
</dependencies>
101126

102127
<build>

src/main/java/com/babelqueue/spring/BabelQueuePublisher.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.babelqueue.EnvelopeCodec;
55
import com.babelqueue.PolyglotMessage;
66
import java.util.Map;
7+
import org.springframework.amqp.core.MessagePostProcessor;
78
import org.springframework.amqp.rabbit.core.RabbitTemplate;
89

910
/**
@@ -15,6 +16,11 @@
1516
* <pre>
1617
* babelQueue.publish("urn:babel:orders:created", Map.of("order_id", 1042L), "orders");
1718
* </pre>
19+
*
20+
* <p>{@link #publishWithHeaders} additionally carries out-of-band transport headers (e.g.
21+
* a W3C {@code traceparent}, ADR-0028) on the AMQP {@code MessageProperties} headers,
22+
* <b>beside</b> the contract {@code x-*} headers — the produce-side seam the optional core
23+
* {@code com.babelqueue.otel.HeaderSender} wires to.
1824
*/
1925
public class BabelQueuePublisher {
2026

@@ -47,6 +53,26 @@ public String publish(String urn, Map<String, Object> data, String queue, String
4753
return envelope.meta().id();
4854
}
4955

56+
/**
57+
* Publish an already-built {@code envelope} together with out-of-band transport
58+
* {@code headers} (e.g. a W3C {@code traceparent}, ADR-0028). The headers are written
59+
* onto the AMQP {@code MessageProperties} headers <b>beside</b> the contract {@code x-*}
60+
* headers ({@link SpringHeaders#apply}: a contract header always wins a key collision),
61+
* never inside the frozen envelope (GR-1). This is the produce-side seam the optional
62+
* core {@code com.babelqueue.otel.HeaderSender} wires to; with no/empty headers it is
63+
* equivalent to a plain publish. Returns {@code meta.id}.
64+
*/
65+
public String publishWithHeaders(Envelope envelope, Map<String, String> headers) {
66+
String target = envelope.meta() != null && envelope.meta().queue() != null
67+
&& !envelope.meta().queue().isBlank() ? envelope.meta().queue() : defaultQueue;
68+
MessagePostProcessor addHeaders = message -> {
69+
SpringHeaders.apply(message.getMessageProperties(), headers);
70+
return message;
71+
};
72+
rabbitTemplate.convertAndSend(target, (Object) envelope, addHeaders);
73+
return envelope.meta().id();
74+
}
75+
5076
/** Publish a {@link PolyglotMessage} to the default queue. Returns meta.id. */
5177
public String publish(PolyglotMessage message) {
5278
return publish(message, defaultQueue);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.babelqueue.spring;
2+
3+
import java.util.LinkedHashMap;
4+
import java.util.Map;
5+
import org.springframework.amqp.core.Message;
6+
import org.springframework.amqp.core.MessageProperties;
7+
8+
/**
9+
* The out-of-band transport-header seam for the Spring AMQP adapter (ADR-0028).
10+
*
11+
* <p>Out-of-band headers (e.g. a W3C {@code traceparent}) ride on the AMQP
12+
* {@code MessageProperties} headers <b>beside</b> the contract {@code x-*} headers
13+
* ({@code x-schema-version}/{@code x-source-lang}/{@code x-attempts}) that
14+
* {@link BabelQueueMessageConverter} stamps — never inside the frozen envelope (GR-1).
15+
*
16+
* <ul>
17+
* <li><b>Produce</b> ({@link #apply}): {@link BabelQueuePublisher#publishWithHeaders}
18+
* merges the headers onto the message's properties; a contract header already present
19+
* always wins a key collision, and blank keys/values are skipped.</li>
20+
* <li><b>Consume</b> ({@link #of(Message)} / {@link #of(MessageProperties)}): surfaces a
21+
* delivered message's headers as a flat {@code Map<String, String>}, the seam a
22+
* {@code @RabbitListener} wires to the optional core
23+
* {@code com.babelqueue.otel.Tracing#wrapHandler(io.opentelemetry.api.trace.Tracer,
24+
* com.babelqueue.idempotency.Handler, java.util.function.Supplier)} so a carried
25+
* {@code traceparent} makes the consumer span a true child of the producer span.</li>
26+
* </ul>
27+
*
28+
* <pre>{@code
29+
* @RabbitListener(queues = "orders")
30+
* void onOrder(Envelope env, Message message) throws Exception {
31+
* Tracing.wrapHandler(tracer, h, () -> SpringHeaders.of(message)).handle(env);
32+
* }
33+
* }</pre>
34+
*
35+
* <p>Reading or writing the headers requires no OpenTelemetry dependency; the map is a
36+
* plain {@code Map<String, String>}.
37+
*/
38+
public final class SpringHeaders {
39+
40+
private SpringHeaders() {}
41+
42+
/**
43+
* Writes the out-of-band {@code headers} onto {@code props} beside the contract
44+
* {@code x-*} headers. A blank key or value is skipped, and a header is never written
45+
* over a property already set (the contract projection wins). A {@code null}/empty map
46+
* is a no-op.
47+
*/
48+
static void apply(MessageProperties props, Map<String, String> headers) {
49+
if (props == null || headers == null || headers.isEmpty()) {
50+
return;
51+
}
52+
for (Map.Entry<String, String> e : headers.entrySet()) {
53+
String key = e.getKey();
54+
String value = e.getValue();
55+
if (key == null || key.isEmpty() || value == null || value.isEmpty()) {
56+
continue;
57+
}
58+
if (props.getHeader(key) != null) {
59+
continue; // contract / pre-existing header wins
60+
}
61+
props.setHeader(key, value);
62+
}
63+
}
64+
65+
/**
66+
* Returns the delivered message's {@code MessageProperties} headers as a
67+
* {@code Map<String, String>}. An empty map for a {@code null} message.
68+
*/
69+
public static Map<String, String> of(Message message) {
70+
return message == null ? Map.of() : of(message.getMessageProperties());
71+
}
72+
73+
/**
74+
* Returns {@code props}' headers as a flat {@code Map<String, String>}
75+
* (each value rendered via {@code toString}; blank values dropped). An empty map for
76+
* {@code null} props or no headers.
77+
*/
78+
public static Map<String, String> of(MessageProperties props) {
79+
Map<String, String> out = new LinkedHashMap<>();
80+
if (props == null) {
81+
return out;
82+
}
83+
for (Map.Entry<String, Object> e : props.getHeaders().entrySet()) {
84+
Object value = e.getValue();
85+
if (value != null) {
86+
String str = value.toString();
87+
if (!str.isEmpty()) {
88+
out.put(e.getKey(), str);
89+
}
90+
}
91+
}
92+
return out;
93+
}
94+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.babelqueue.spring;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.eq;
5+
import static org.mockito.Mockito.mock;
6+
import static org.mockito.Mockito.verify;
7+
8+
import com.babelqueue.Envelope;
9+
import com.babelqueue.EnvelopeCodec;
10+
import io.opentelemetry.api.trace.Tracer;
11+
import io.opentelemetry.sdk.OpenTelemetrySdk;
12+
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
13+
import io.opentelemetry.sdk.trace.SdkTracerProvider;
14+
import io.opentelemetry.sdk.trace.data.SpanData;
15+
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
16+
import java.nio.charset.StandardCharsets;
17+
import java.util.List;
18+
import java.util.Map;
19+
import org.junit.jupiter.api.Test;
20+
import org.mockito.ArgumentCaptor;
21+
import org.springframework.amqp.core.Message;
22+
import org.springframework.amqp.core.MessagePostProcessor;
23+
import org.springframework.amqp.core.MessageProperties;
24+
import org.springframework.amqp.rabbit.core.RabbitTemplate;
25+
26+
/**
27+
* The Spring AMQP wiring of the out-of-band {@code traceparent} header (ADR-0028): produce
28+
* writes it onto {@code MessageProperties} headers beside the contract {@code x-*} headers
29+
* (contract wins), consume surfaces them as a {@code Map<String,String>}, and a
30+
* publish→consume round-trip (converter + post-processor) carries the header end to end so
31+
* the consumer span is a true child of the producer span. No broker (the
32+
* {@code RabbitTemplate} is mocked; the converter runs in-process).
33+
*/
34+
class SpringTraceparentTest {
35+
36+
private static final String TRACEPARENT = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
37+
private final BabelQueueMessageConverter converter = new BabelQueueMessageConverter("default");
38+
39+
@Test
40+
void applyWritesHeadersBesideContractHeadersAndContractWins() {
41+
Envelope env = EnvelopeCodec.make("urn:babel:orders:created", Map.of("x", 1L), "orders", "trace-1");
42+
Message message = converter.toMessage(env, new MessageProperties()); // stamps x-*
43+
MessageProperties props = message.getMessageProperties();
44+
45+
SpringHeaders.apply(props, Map.of(
46+
"traceparent", TRACEPARENT,
47+
"x-schema-version", "999")); // collides with a contract header -> must not win
48+
49+
assertThat((Object) props.getHeader("traceparent")).isEqualTo(TRACEPARENT);
50+
assertThat((Object) props.getHeader("x-schema-version")).isEqualTo(1); // contract value preserved
51+
}
52+
53+
@Test
54+
void applyIsANoOpForBlankAndEmptyInputs() {
55+
MessageProperties props = new MessageProperties();
56+
SpringHeaders.apply(props, Map.of());
57+
SpringHeaders.apply(props, null);
58+
SpringHeaders.apply(null, Map.of("traceparent", TRACEPARENT));
59+
SpringHeaders.apply(props, Map.of("", "x")); // blank key skipped
60+
assertThat(props.getHeaders()).isEmpty();
61+
}
62+
63+
@Test
64+
void ofSurfacesDeliveredHeadersAsAMap() {
65+
MessageProperties props = new MessageProperties();
66+
props.setHeader("traceparent", TRACEPARENT);
67+
props.setHeader("x-schema-version", 1);
68+
Message message = new Message("{}".getBytes(StandardCharsets.UTF_8), props);
69+
70+
Map<String, String> headers = SpringHeaders.of(message);
71+
assertThat(headers).containsEntry("traceparent", TRACEPARENT).containsEntry("x-schema-version", "1");
72+
assertThat(SpringHeaders.of((Message) null)).isEmpty();
73+
assertThat(SpringHeaders.of((MessageProperties) null)).isEmpty();
74+
}
75+
76+
@Test
77+
void publishWithHeadersSendsThroughAPostProcessorThatStampsTheHeader() {
78+
RabbitTemplate template = mock(RabbitTemplate.class);
79+
BabelQueuePublisher publisher = new BabelQueuePublisher(template, "default");
80+
Envelope env = EnvelopeCodec.make("urn:babel:orders:created", Map.of("x", 1L), "orders", "trace-1");
81+
82+
String id = publisher.publishWithHeaders(env, Map.of("traceparent", TRACEPARENT));
83+
assertThat(id).isEqualTo(env.meta().id());
84+
85+
ArgumentCaptor<Object> payload = ArgumentCaptor.forClass(Object.class);
86+
ArgumentCaptor<MessagePostProcessor> post = ArgumentCaptor.forClass(MessagePostProcessor.class);
87+
verify(template).convertAndSend(eq("orders"), payload.capture(), post.capture());
88+
89+
// Drive the converter + the captured post-processor to get the final wire message.
90+
Message converted = converter.toMessage(payload.getValue(), new MessageProperties());
91+
Message finalMsg = post.getValue().postProcessMessage(converted);
92+
assertThat((Object) finalMsg.getMessageProperties().getHeader("traceparent")).isEqualTo(TRACEPARENT);
93+
// GR-1: traceparent is not in the body; GR-4: trace_id preserved.
94+
String body = new String(finalMsg.getBody(), StandardCharsets.UTF_8);
95+
assertThat(body).doesNotContain("traceparent");
96+
assertThat(EnvelopeCodec.decode(body).traceId()).isEqualTo("trace-1");
97+
}
98+
99+
@Test
100+
void endToEndTraceparentMakesConsumerSpanAChildOfTheProducerSpan() throws Exception {
101+
InMemorySpanExporter exporter = InMemorySpanExporter.create();
102+
SdkTracerProvider provider = SdkTracerProvider.builder()
103+
.addSpanProcessor(SimpleSpanProcessor.create(exporter))
104+
.build();
105+
OpenTelemetrySdk sdk = OpenTelemetrySdk.builder().setTracerProvider(provider).build();
106+
Tracer tracer = sdk.getTracer("test");
107+
108+
RabbitTemplate template = mock(RabbitTemplate.class);
109+
BabelQueuePublisher publisher = new BabelQueuePublisher(template, "default");
110+
111+
// Producer: a PRODUCER span injects traceparent onto the headers via the otel
112+
// HeaderSender seam -> BabelQueuePublisher.publishWithHeaders.
113+
com.babelqueue.otel.Tracing.publish(
114+
tracer, "urn:babel:orders:created", Map.of("order_id", 7), "orders",
115+
(envelope, headers) -> publisher.publishWithHeaders(envelope, headers));
116+
117+
// Reconstruct the delivered AMQP message (converter + post-processor).
118+
ArgumentCaptor<Object> payload = ArgumentCaptor.forClass(Object.class);
119+
ArgumentCaptor<MessagePostProcessor> post = ArgumentCaptor.forClass(MessagePostProcessor.class);
120+
verify(template).convertAndSend(eq("orders"), payload.capture(), post.capture());
121+
Message delivered = post.getValue().postProcessMessage(
122+
converter.toMessage(payload.getValue(), new MessageProperties()));
123+
Envelope env = (Envelope) converter.fromMessage(delivered);
124+
125+
// Consumer: wrapHandler reads the delivered headers and parents the CONSUMER span on
126+
// the carried traceparent.
127+
com.babelqueue.otel.Tracing.wrapHandler(tracer, e -> { }, () -> SpringHeaders.of(delivered))
128+
.handle(env);
129+
130+
List<SpanData> spans = exporter.getFinishedSpanItems();
131+
SpanData producer = spanByName(spans, "publish urn:babel:orders:created");
132+
SpanData consumer = spanByName(spans, "process urn:babel:orders:created");
133+
assertThat(consumer.getParentSpanContext().getSpanId()).isEqualTo(producer.getSpanContext().getSpanId());
134+
assertThat(consumer.getTraceId()).isEqualTo(producer.getSpanContext().getTraceId());
135+
assertThat(consumer.getParentSpanContext().isRemote()).isTrue();
136+
provider.close();
137+
}
138+
139+
private static SpanData spanByName(List<SpanData> spans, String name) {
140+
return spans.stream().filter(s -> s.getName().equals(name)).findFirst()
141+
.orElseThrow(() -> new AssertionError("span not found: " + name));
142+
}
143+
}

0 commit comments

Comments
 (0)